Files

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
}