118 lines
4.1 KiB
Go
118 lines
4.1 KiB
Go
package updater
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var releaseTagPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
|
|
var githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
|
|
|
|
// InstallTarget pins both the installer and the source tree to one revision.
|
|
// Release is set only when installing checksum-verified published assets.
|
|
type InstallTarget struct {
|
|
Release string
|
|
Revision string
|
|
Script string
|
|
}
|
|
|
|
// InstallerSource uses the same GitHub client and token as release discovery.
|
|
type InstallerSource struct {
|
|
github github
|
|
repo string
|
|
}
|
|
|
|
func NewInstallerSource(repoURL string) (InstallerSource, error) {
|
|
repo := officialRepo
|
|
if repoURL != "" {
|
|
if strings.HasPrefix(repoURL, "[email protected]:") {
|
|
repo = strings.TrimPrefix(repoURL, "[email protected]:")
|
|
} else {
|
|
u, err := url.Parse(repoURL)
|
|
if err != nil || u.Hostname() != "github.com" || (u.Scheme != "https" && u.Scheme != "ssh") || u.RawQuery != "" || u.Fragment != "" {
|
|
return InstallerSource{}, fmt.Errorf("FELIS_REPO_URL must name a GitHub repository over HTTPS or SSH")
|
|
}
|
|
repo = strings.TrimPrefix(u.Path, "/")
|
|
}
|
|
repo = strings.TrimSuffix(strings.TrimSuffix(repo, "/"), ".git")
|
|
if !githubRepoPattern.MatchString(repo) || strings.Contains(repo, "..") {
|
|
return InstallerSource{}, fmt.Errorf("FELIS_REPO_URL must name a GitHub owner/repository")
|
|
}
|
|
}
|
|
return InstallerSource{github: newGitHub(), repo: repo}, nil
|
|
}
|
|
|
|
func (s InstallerSource) RepoURL() string { return "https://github.com/" + s.repo + ".git" }
|
|
|
|
// Prepare downloads the complete script before any host changes. A moving ref
|
|
// (including main) is resolved once; the installer receives that same full SHA.
|
|
func (s InstallerSource) Prepare(ctx context.Context, release, ref string) (InstallTarget, error) {
|
|
if ref == "" {
|
|
endpoint := "releases/latest"
|
|
if release != "" {
|
|
endpoint = "releases/tags/" + url.PathEscape(release)
|
|
}
|
|
tag, err := s.github.releaseTag(ctx, s.repo, endpoint)
|
|
if err != nil {
|
|
return InstallTarget{}, err
|
|
}
|
|
if !releaseTagPattern.MatchString(tag) || (release != "" && tag != release) {
|
|
return InstallTarget{}, fmt.Errorf("unexpected Felis release tag %q", tag)
|
|
}
|
|
release, ref = tag, tag
|
|
}
|
|
resp, err := s.github.get(ctx, "/repos/"+s.repo+"/commits/"+url.PathEscape(ref), "application/vnd.github+json")
|
|
if err != nil {
|
|
return InstallTarget{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return InstallTarget{}, fmt.Errorf("github: resolve ref %q: HTTP %d", ref, resp.StatusCode)
|
|
}
|
|
var commit struct {
|
|
SHA string `json:"sha"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil {
|
|
return InstallTarget{}, fmt.Errorf("github: decode commit: %w", err)
|
|
}
|
|
if _, err := hex.DecodeString(commit.SHA); err != nil || len(commit.SHA) != 40 {
|
|
return InstallTarget{}, fmt.Errorf("github: ref %q did not resolve to a full commit SHA", ref)
|
|
}
|
|
commit.SHA = strings.ToLower(commit.SHA)
|
|
if _, err := hex.DecodeString(ref); err == nil && len(ref) == 40 && !strings.EqualFold(ref, commit.SHA) {
|
|
return InstallTarget{}, fmt.Errorf("github: resolved commit %s differs from requested %s", commit.SHA, ref)
|
|
}
|
|
script, err := s.script(ctx, commit.SHA)
|
|
if err != nil {
|
|
return InstallTarget{}, err
|
|
}
|
|
return InstallTarget{Release: release, Revision: commit.SHA, Script: script}, nil
|
|
}
|
|
|
|
func (s InstallerSource) script(ctx context.Context, revision string) (string, error) {
|
|
resp, err := s.github.get(ctx, "/repos/"+s.repo+"/contents/deploy/bootstrap.sh?ref="+revision, "application/vnd.github.raw+json")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("github: download installer at %s: HTTP %d", revision, resp.StatusCode)
|
|
}
|
|
const limit = 2 << 20
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
|
if err != nil {
|
|
return "", fmt.Errorf("github: read installer: %w", err)
|
|
}
|
|
if len(raw) > limit || !strings.HasPrefix(string(raw), "#!/") {
|
|
return "", fmt.Errorf("github: installer is oversized or is not a shell script")
|
|
}
|
|
return string(raw), nil
|
|
}
|