From f3cd3128aaddd962e26559fc7f8140f5256d1f45 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 14 Jul 2026 01:15:40 +0200 Subject: [PATCH 1/3] feat: update notifier and `asobi upgrade` self-update Phase A - notifier: after a command runs, print a one-line stderr notice when a newer release exists. Best-effort and non-blocking - consults a ~/.asobi/version_check.json cache (refreshed at most once/24h), stays silent on any error, on ASOBI_NO_UPDATE_CHECK, in CI, for dev builds, and for the version/upgrade/help commands. Phase B - `asobi upgrade`: download the release asset for this platform, verify it against the release checksums (fail closed), and atomically replace the running binary. Refuses package-manager-managed installs (Homebrew/Scoop/Nix) and read-only locations, pointing at the right tool. Verified end to end against the live v0.2.0 release: notifier fires for an older build, opt-out/CI/version paths stay silent, and a 0.1.0 binary self-upgrades to 0.2.0 after a real checksum-verified download. --- cmd/asobi/main.go | 12 ++ internal/update/update.go | 185 ++++++++++++++++++++++++ internal/update/update_test.go | 117 +++++++++++++++ internal/update/upgrade.go | 249 ++++++++++++++++++++++++++++++++ internal/update/upgrade_test.go | 189 ++++++++++++++++++++++++ 5 files changed, 752 insertions(+) create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go create mode 100644 internal/update/upgrade.go create mode 100644 internal/update/upgrade_test.go diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 4e268f5..98a0eb2 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -17,6 +17,7 @@ import ( "github.com/widgrensit/asobi-cli/internal/dev" "github.com/widgrensit/asobi-cli/internal/scaffold" "github.com/widgrensit/asobi-cli/internal/template" + "github.com/widgrensit/asobi-cli/internal/update" ) const defaultSaasURL = "https://console.asobi.dev" @@ -70,6 +71,8 @@ func main() { cmdHealth() case "config": cmdConfig() + case "upgrade": + cmdUpgrade() case "version", "--version", "-v": cmdVersion() case "help", "--help", "-h": @@ -79,6 +82,8 @@ func main() { usage() os.Exit(1) } + + update.Notify(version, os.Args[1], os.Stderr) } func usage() { @@ -106,6 +111,7 @@ Usage: asobi health [env] [--game ] Check engine health (of an environment) asobi config set Set config (url, api_key, saas_url) asobi config show Show current config + asobi upgrade Update asobi to the latest release asobi version Show version, commit, and build date asobi help Show this help @@ -126,6 +132,12 @@ func cmdVersion() { fmt.Printf(" built: %s\n", date) } +func cmdUpgrade() { + if err := update.SelfUpgrade(version, os.Stdout); err != nil { + fatal("%v", err) + } +} + // --- Login/Logout/Whoami --- func cmdLogin() { diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..b162364 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,185 @@ +// Package update checks for and installs newer asobi CLI releases. The version +// check (Notify) is best-effort and never blocks or fails a command; the +// self-upgrade (see upgrade.go) verifies downloads against the release +// checksums before replacing the running binary. +package update + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/widgrensit/asobi-cli/internal/config" +) + +const repo = "widgrensit/asobi-cli" + +const installCmd = "curl -fsSL https://raw.githubusercontent.com/widgrensit/asobi-cli/main/install.sh | sh" + +// checkInterval is how long a cached version-check result is trusted before we +// hit the network again. +const checkInterval = 24 * time.Hour + +// Overridable in tests. +var ( + apiBaseURL = "https://api.github.com" + releasesBaseURL = "https://github.com/" + repo + "/releases/download" + httpClient = &http.Client{Timeout: 3 * time.Second} +) + +// Notify prints a one-line notice to w when a newer release than current +// exists. It is best-effort: it consults a ~/.asobi cache first, refreshes at +// most once per checkInterval, and stays silent on any error, opt-out, in CI, +// or for a dev build. cmd is the invoked subcommand, used to skip commands +// where a notice is noise (version, upgrade, help). +func Notify(current, cmd string, w io.Writer) { + if !shouldCheck(current, cmd) { + return + } + latest, err := cachedOrFetch() + if err != nil || latest == "" { + return + } + if IsNewer(latest, current) { + fmt.Fprintf(w, "\nA new asobi is available: %s (you have %s).\nUpgrade: asobi upgrade (or: %s)\n", + latest, normalize(current), installCmd) + } +} + +func shouldCheck(current, cmd string) bool { + switch { + case os.Getenv("ASOBI_NO_UPDATE_CHECK") != "": + return false + case os.Getenv("CI") != "": + return false + case current == "" || current == "dev": + return false + } + switch cmd { + case "version", "--version", "-v", "upgrade", "help", "--help", "-h": + return false + } + return true +} + +type cache struct { + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest"` +} + +func cachePath() string { + return filepath.Join(config.Dir(), "version_check.json") +} + +// cachedOrFetch returns the latest known release tag, using the cache when it +// is fresh and otherwise refreshing it from the releases API. +func cachedOrFetch() (string, error) { + if c, ok := readCache(); ok && time.Since(c.CheckedAt) < checkInterval { + return c.Latest, nil + } + latest, err := LatestReleaseTag(context.Background()) + if err != nil { + return "", err + } + writeCache(cache{CheckedAt: time.Now(), Latest: latest}) + return latest, nil +} + +func readCache() (cache, bool) { + data, err := os.ReadFile(cachePath()) + if err != nil { + return cache{}, false + } + var c cache + if err := json.Unmarshal(data, &c); err != nil { + return cache{}, false + } + return c, true +} + +func writeCache(c cache) { + data, err := json.Marshal(c) + if err != nil { + return + } + if err := os.MkdirAll(config.Dir(), 0o700); err != nil { + return + } + _ = os.WriteFile(cachePath(), data, 0o600) +} + +// LatestReleaseTag returns the tag_name of the newest published release. +func LatestReleaseTag(ctx context.Context) (string, error) { + url := fmt.Sprintf("%s/repos/%s/releases/latest", apiBaseURL, repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "asobi-cli") + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("releases API returned %s", resp.Status) + } + var rel struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&rel); err != nil { + return "", err + } + if rel.TagName == "" { + return "", fmt.Errorf("no tag_name in latest release") + } + return rel.TagName, nil +} + +// IsNewer reports whether latest is a strictly higher version than current. +func IsNewer(latest, current string) bool { + return CompareVersions(latest, current) > 0 +} + +// CompareVersions compares two vMAJOR.MINOR.PATCH strings (a leading "v" is +// optional), returning -1, 0 or 1. Any pre-release/build suffix is ignored; +// unparseable components sort as 0. +func CompareVersions(a, b string) int { + as, bs := parse(a), parse(b) + for i := 0; i < 3; i++ { + if as[i] != bs[i] { + if as[i] < bs[i] { + return -1 + } + return 1 + } + } + return 0 +} + +func parse(v string) [3]int { + v = normalize(v) + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + var out [3]int + for i, part := range strings.SplitN(v, ".", 3) { + if i > 2 { + break + } + out[i], _ = strconv.Atoi(part) + } + return out +} + +func normalize(v string) string { + return strings.TrimPrefix(strings.TrimSpace(v), "v") +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..77da7c2 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,117 @@ +package update + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"v0.2.0", "v0.1.2", 1}, + {"0.2.0", "v0.2.0", 0}, + {"v1.0.0", "v0.9.9", 1}, + {"v0.2.0", "v0.2.1", -1}, + {"v0.2.10", "v0.2.9", 1}, + {"v0.2.0-rc1", "v0.2.0", 0}, + {"v2.0.0", "v10.0.0", -1}, + } + for _, c := range cases { + if got := CompareVersions(c.a, c.b); got != c.want { + t.Errorf("CompareVersions(%q,%q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +func TestIsNewer(t *testing.T) { + if !IsNewer("v0.3.0", "0.2.0") { + t.Error("0.3.0 should be newer than 0.2.0") + } + if IsNewer("v0.2.0", "0.2.0") { + t.Error("equal versions are not newer") + } + if IsNewer("v0.1.0", "0.2.0") { + t.Error("older is not newer") + } +} + +func TestLatestReleaseTag(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/releases/latest") { + http.NotFound(w, r) + return + } + w.Write([]byte(`{"tag_name":"v0.9.0"}`)) + })) + defer srv.Close() + old := apiBaseURL + apiBaseURL = srv.URL + defer func() { apiBaseURL = old }() + + tag, err := LatestReleaseTag(context.Background()) + if err != nil { + t.Fatal(err) + } + if tag != "v0.9.0" { + t.Errorf("tag = %q, want v0.9.0", tag) + } +} + +func TestNotify(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("ASOBI_NO_UPDATE_CHECK", "") + t.Setenv("CI", "") + // Fresh cache advertising a newer release; Notify must not hit the network. + writeCache(cache{CheckedAt: time.Now(), Latest: "v0.9.0"}) + + var buf bytes.Buffer + Notify("0.2.0", "deploy", &buf) + if !strings.Contains(buf.String(), "v0.9.0") { + t.Errorf("expected upgrade notice, got %q", buf.String()) + } + + buf.Reset() + Notify("0.9.0", "deploy", &buf) + if buf.Len() != 0 { + t.Errorf("no notice when current == latest, got %q", buf.String()) + } +} + +func TestNotifySkips(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + writeCache(cache{CheckedAt: time.Now(), Latest: "v0.9.0"}) + + cases := []struct { + name string + current string + cmd string + env map[string]string + }{ + {"opt-out", "0.2.0", "deploy", map[string]string{"ASOBI_NO_UPDATE_CHECK": "1"}}, + {"ci", "0.2.0", "deploy", map[string]string{"CI": "true"}}, + {"dev build", "dev", "deploy", nil}, + {"version cmd", "0.2.0", "version", nil}, + {"upgrade cmd", "0.2.0", "upgrade", nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv("ASOBI_NO_UPDATE_CHECK", "") + t.Setenv("CI", "") + for k, v := range c.env { + t.Setenv(k, v) + } + var buf bytes.Buffer + Notify(c.current, c.cmd, &buf) + if buf.Len() != 0 { + t.Errorf("expected no notice, got %q", buf.String()) + } + }) + } +} diff --git a/internal/update/upgrade.go b/internal/update/upgrade.go new file mode 100644 index 0000000..102da52 --- /dev/null +++ b/internal/update/upgrade.go @@ -0,0 +1,249 @@ +package update + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// maxDownload bounds a release asset or checksums download. Asset archives are +// a few MB; this is a generous ceiling against a hostile oversized response. +const maxDownload = 64 << 20 + +var downloadClient = &http.Client{Timeout: 2 * time.Minute} + +// SelfUpgrade downloads the latest release for this platform, verifies it +// against the release checksums, and atomically replaces the running binary. +// It refuses to touch a package-manager-managed or read-only install. +func SelfUpgrade(current string, w io.Writer) error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("locate current binary: %w", err) + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + + latest, err := LatestReleaseTag(context.Background()) + if err != nil { + return fmt.Errorf("check latest release: %w", err) + } + if !IsNewer(latest, current) { + fmt.Fprintf(w, "asobi is already up to date (%s).\n", normalize(current)) + return nil + } + + if pm := managedBy(exe); pm != "" { + return fmt.Errorf("asobi was installed with %s; upgrade with it instead (e.g. `%s`)", pm, pmUpgradeHint(pm)) + } + + name := assetName() + fmt.Fprintf(w, "Upgrading asobi %s -> %s ...\n", normalize(current), latest) + + asset, err := download(fmt.Sprintf("%s/%s/%s", releasesBaseURL, latest, name)) + if err != nil { + return fmt.Errorf("download %s: %w", name, err) + } + sums, err := download(fmt.Sprintf("%s/%s/checksums.txt", releasesBaseURL, latest)) + if err != nil { + return fmt.Errorf("download checksums: %w", err) + } + if err := verifyChecksum(asset, sums, name); err != nil { + return err + } + + bin, err := extractBinary(asset, name) + if err != nil { + return err + } + if err := replaceExecutable(exe, bin); err != nil { + return err + } + + fmt.Fprintf(w, "Upgraded to %s.\n", latest) + return nil +} + +// assetName is the release archive for the running platform, matching the +// goreleaser `asobi_{{.Os}}_{{.Arch}}` name template. +func assetName() string { + ext := ".tar.gz" + if runtime.GOOS == "windows" { + ext = ".zip" + } + return fmt.Sprintf("asobi_%s_%s%s", runtime.GOOS, runtime.GOARCH, ext) +} + +func download(url string) ([]byte, error) { + resp, err := downloadClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s returned %s", url, resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1)) + if err != nil { + return nil, err + } + if len(data) > maxDownload { + return nil, fmt.Errorf("%s exceeds %d byte limit", url, maxDownload) + } + return data, nil +} + +// verifyChecksum fails closed: the asset's SHA-256 must appear in sums against +// its exact filename, or the upgrade is refused. +func verifyChecksum(asset, sums []byte, name string) error { + want := "" + for _, line := range strings.Split(string(sums), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == name { + want = strings.ToLower(fields[0]) + break + } + } + if want == "" { + return fmt.Errorf("no checksum for %s in checksums.txt", name) + } + sum := sha256.Sum256(asset) + got := hex.EncodeToString(sum[:]) + if got != want { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", name, got, want) + } + return nil +} + +// extractBinary pulls the single `asobi` executable out of a verified archive. +func extractBinary(archive []byte, name string) ([]byte, error) { + if strings.HasSuffix(name, ".zip") { + return extractZip(archive) + } + return extractTarGz(archive) +} + +func extractTarGz(archive []byte) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("gunzip: %w", err) + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read archive: %w", err) + } + if isBinaryEntry(hdr.Name) { + return io.ReadAll(io.LimitReader(tr, maxDownload)) + } + } + return nil, fmt.Errorf("asobi binary not found in archive") +} + +func extractZip(archive []byte) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("open zip: %w", err) + } + for _, f := range zr.File { + if isBinaryEntry(f.Name) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, maxDownload)) + } + } + return nil, fmt.Errorf("asobi binary not found in archive") +} + +func isBinaryEntry(path string) bool { + base := filepath.Base(filepath.ToSlash(path)) + return base == "asobi" || base == "asobi.exe" +} + +// replaceExecutable writes bin next to exe and atomically renames it over the +// running binary. On Windows a running image cannot be overwritten, so the +// current binary is moved aside first. +func replaceExecutable(exe string, bin []byte) error { + if len(bin) == 0 { + return fmt.Errorf("refusing to install an empty binary") + } + dir := filepath.Dir(exe) + tmp, err := os.CreateTemp(dir, ".asobi-upgrade-*") + if err != nil { + return fmt.Errorf("write to %s: %w (need write access; use sudo or your package manager)", dir, err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(bin); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, 0o755); err != nil { + return err + } + if runtime.GOOS == "windows" { + old := exe + ".old" + os.Remove(old) + if err := os.Rename(exe, old); err != nil { + return fmt.Errorf("move current binary aside: %w", err) + } + if err := os.Rename(tmpName, exe); err != nil { + _ = os.Rename(old, exe) + return fmt.Errorf("install new binary: %w", err) + } + return nil + } + if err := os.Rename(tmpName, exe); err != nil { + return fmt.Errorf("install new binary: %w", err) + } + return nil +} + +// managedBy returns the package manager that owns exe, or "" if it looks like a +// plain install we may replace in place. +func managedBy(exe string) string { + p := filepath.ToSlash(exe) + switch { + case strings.Contains(p, "/Cellar/") || strings.Contains(p, "/homebrew/") || strings.Contains(p, "linuxbrew"): + return "Homebrew" + case strings.Contains(strings.ToLower(p), "/scoop/"): + return "Scoop" + case strings.Contains(p, "/nix/store/"): + return "Nix" + } + return "" +} + +func pmUpgradeHint(pm string) string { + switch pm { + case "Homebrew": + return "brew upgrade asobi" + case "Scoop": + return "scoop update asobi" + default: + return "your package manager" + } +} diff --git a/internal/update/upgrade_test.go b/internal/update/upgrade_test.go new file mode 100644 index 0000000..c306e22 --- /dev/null +++ b/internal/update/upgrade_test.go @@ -0,0 +1,189 @@ +package update + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func tarGz(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(content))}); err != nil { + t.Fatal(err) + } + tw.Write(content) + tw.Close() + gz.Close() + return buf.Bytes() +} + +func zipArchive(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + f, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + f.Write(content) + zw.Close() + return buf.Bytes() +} + +func TestAssetName(t *testing.T) { + got := assetName() + if !strings.HasPrefix(got, "asobi_"+runtime.GOOS+"_"+runtime.GOARCH) { + t.Errorf("assetName = %q, missing os/arch", got) + } + wantExt := ".tar.gz" + if runtime.GOOS == "windows" { + wantExt = ".zip" + } + if !strings.HasSuffix(got, wantExt) { + t.Errorf("assetName = %q, want suffix %q", got, wantExt) + } +} + +func TestVerifyChecksum(t *testing.T) { + asset := []byte("pretend archive bytes") + sum := sha256.Sum256(asset) + good := fmt.Sprintf("%s asobi_linux_amd64.tar.gz\n%s other.zip\n", + hex.EncodeToString(sum[:]), strings.Repeat("0", 64)) + + if err := verifyChecksum(asset, []byte(good), "asobi_linux_amd64.tar.gz"); err != nil { + t.Errorf("valid checksum rejected: %v", err) + } + if err := verifyChecksum([]byte("tampered"), []byte(good), "asobi_linux_amd64.tar.gz"); err == nil { + t.Error("tampered asset must fail checksum") + } + if err := verifyChecksum(asset, []byte(good), "missing.tar.gz"); err == nil { + t.Error("missing checksum entry must fail") + } +} + +func TestExtractBinary(t *testing.T) { + want := []byte("#!/fake asobi binary\x00\x01") + + got, err := extractBinary(tarGz(t, "asobi", want), "asobi_linux_amd64.tar.gz") + if err != nil { + t.Fatalf("tar.gz: %v", err) + } + if !bytes.Equal(got, want) { + t.Error("tar.gz binary content mismatch") + } + + got, err = extractBinary(zipArchive(t, "asobi.exe", want), "asobi_windows_amd64.zip") + if err != nil { + t.Fatalf("zip: %v", err) + } + if !bytes.Equal(got, want) { + t.Error("zip binary content mismatch") + } + + if _, err := extractBinary(tarGz(t, "README.md", want), "asobi_linux_amd64.tar.gz"); err == nil { + t.Error("archive without asobi binary must error") + } +} + +func TestReplaceExecutable(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "asobi") + if err := os.WriteFile(exe, []byte("old binary"), 0o755); err != nil { + t.Fatal(err) + } + if err := replaceExecutable(exe, []byte("new binary")); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if string(got) != "new binary" { + t.Errorf("content = %q, want new binary", got) + } + if info, _ := os.Stat(exe); info.Mode().Perm()&0o100 == 0 { + t.Error("replaced binary is not executable") + } + if err := replaceExecutable(exe, nil); err == nil { + t.Error("empty binary must be refused") + } +} + +func TestManagedBy(t *testing.T) { + cases := map[string]string{ + "/opt/homebrew/bin/asobi": "Homebrew", + "/home/linuxbrew/.linuxbrew/bin/asobi": "Homebrew", + "/Users/x/scoop/apps/asobi/current/asobi.exe": "Scoop", + "/nix/store/abc-asobi/bin/asobi": "Nix", + "/home/x/.local/bin/asobi": "", + "/usr/local/bin/asobi": "", + } + for path, want := range cases { + if got := managedBy(path); got != want { + t.Errorf("managedBy(%q) = %q, want %q", path, got, want) + } + } +} + +// Exercises the real download -> verify -> extract pipeline against a server, +// the security-critical path short of replacing the running binary. +func TestDownloadVerifyExtractPipeline(t *testing.T) { + binary := []byte("the new asobi\x7fELF") + name := assetName() + var archive []byte + if strings.HasSuffix(name, ".zip") { + archive = zipArchive(t, "asobi.exe", binary) + } else { + archive = tarGz(t, "asobi", binary) + } + sum := sha256.Sum256(archive) + checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), name) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "checksums.txt"): + w.Write([]byte(checksums)) + case strings.HasSuffix(r.URL.Path, name): + w.Write(archive) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + old := releasesBaseURL + releasesBaseURL = srv.URL + defer func() { releasesBaseURL = old }() + + asset, err := download(fmt.Sprintf("%s/v9.9.9/%s", releasesBaseURL, name)) + if err != nil { + t.Fatal(err) + } + sums, err := download(releasesBaseURL + "/v9.9.9/checksums.txt") + if err != nil { + t.Fatal(err) + } + if err := verifyChecksum(asset, sums, name); err != nil { + t.Fatalf("checksum: %v", err) + } + got, err := extractBinary(asset, name) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, binary) { + t.Error("extracted binary mismatch") + } +} From 8a519cf353ee3aacf57e1a3fda8fcdeef27a6b84 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 14 Jul 2026 01:27:51 +0200 Subject: [PATCH 2/3] fix: harden self-update path (security review) From an adversarial review of the download/verify/replace path: - fail closed on an oversized extracted binary instead of silently truncating it (the checksum covers the archive, not the extracted binary), via readCapped in both tar.gz and zip paths. - refuse redirects to non-HTTPS URLs (no on-path downgrade to plaintext). - validate the release tag as semver before trusting it in a notice or interpolating it into a download URL; ignore a non-semver cached value. - on Windows, tell the user where the original binary is if rollback fails. Adds tests for oversized-extract rejection, HTTP-downgrade refusal, semver validation, and garbage-tag rejection. Live upgrade re-verified. --- internal/update/update.go | 15 ++++++++--- internal/update/update_test.go | 27 +++++++++++++++++++ internal/update/upgrade.go | 47 +++++++++++++++++++++++++++------ internal/update/upgrade_test.go | 26 ++++++++++++++++++ 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/internal/update/update.go b/internal/update/update.go index b162364..585b02b 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strconv" "strings" "time" @@ -27,6 +28,14 @@ const installCmd = "curl -fsSL https://raw.githubusercontent.com/widgrensit/asob // hit the network again. const checkInterval = 24 * time.Hour +// versionRe bounds the shape of a release tag before it is trusted in a notice +// or interpolated into a download URL. +var versionRe = regexp.MustCompile(`^v?\d+\.\d+\.\d+([-+][0-9A-Za-z.\-]+)?$`) + +func validVersion(s string) bool { + return versionRe.MatchString(strings.TrimSpace(s)) +} + // Overridable in tests. var ( apiBaseURL = "https://api.github.com" @@ -81,7 +90,7 @@ func cachePath() string { // cachedOrFetch returns the latest known release tag, using the cache when it // is fresh and otherwise refreshing it from the releases API. func cachedOrFetch() (string, error) { - if c, ok := readCache(); ok && time.Since(c.CheckedAt) < checkInterval { + if c, ok := readCache(); ok && time.Since(c.CheckedAt) < checkInterval && validVersion(c.Latest) { return c.Latest, nil } latest, err := LatestReleaseTag(context.Background()) @@ -138,8 +147,8 @@ func LatestReleaseTag(ctx context.Context) (string, error) { if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&rel); err != nil { return "", err } - if rel.TagName == "" { - return "", fmt.Errorf("no tag_name in latest release") + if !validVersion(rel.TagName) { + return "", fmt.Errorf("invalid release tag %q", rel.TagName) } return rel.TagName, nil } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 77da7c2..580087f 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -115,3 +115,30 @@ func TestNotifySkips(t *testing.T) { }) } } + +func TestValidVersion(t *testing.T) { + for _, ok := range []string{"v0.2.0", "0.2.0", "v1.2.3-rc1", "v0.2.0+build.1"} { + if !validVersion(ok) { + t.Errorf("%q should be valid", ok) + } + } + for _, bad := range []string{"", "latest", "v1.2", "1.2.3.4", "v1.2.x", "0.2.0; rm -rf /", "../../etc"} { + if validVersion(bad) { + t.Errorf("%q should be invalid", bad) + } + } +} + +func TestLatestReleaseTagRejectsGarbage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"tag_name":"not-a-version"}`)) + })) + defer srv.Close() + old := apiBaseURL + apiBaseURL = srv.URL + defer func() { apiBaseURL = old }() + + if _, err := LatestReleaseTag(context.Background()); err == nil { + t.Error("non-semver tag_name must be rejected") + } +} diff --git a/internal/update/upgrade.go b/internal/update/upgrade.go index 102da52..1c5fca2 100644 --- a/internal/update/upgrade.go +++ b/internal/update/upgrade.go @@ -18,11 +18,40 @@ import ( "time" ) -// maxDownload bounds a release asset or checksums download. Asset archives are -// a few MB; this is a generous ceiling against a hostile oversized response. -const maxDownload = 64 << 20 +// maxDownload bounds a release asset or checksums download, and the size of the +// binary extracted from it. Asset archives are a few MB; this is a generous +// ceiling against a hostile oversized response. Var, not const, so tests can +// exercise the overflow guards cheaply. +var maxDownload int64 = 64 << 20 -var downloadClient = &http.Client{Timeout: 2 * time.Minute} +// downloadClient refuses to follow a redirect to a non-HTTPS URL, so a +// downgrade injected on-path can never move the download onto plaintext. +var downloadClient = &http.Client{ + Timeout: 2 * time.Minute, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing redirect to non-HTTPS URL %s", req.URL) + } + return nil + }, +} + +// readCapped reads r fully but fails closed if it exceeds maxDownload, rather +// than silently truncating (the checksum covers the archive, not the extracted +// binary, so a truncated binary would install unnoticed). +func readCapped(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxDownload+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > maxDownload { + return nil, fmt.Errorf("extracted asobi binary exceeds %d byte limit", maxDownload) + } + return data, nil +} // SelfUpgrade downloads the latest release for this platform, verifies it // against the release checksums, and atomically replaces the running binary. @@ -99,7 +128,7 @@ func download(url string) ([]byte, error) { if err != nil { return nil, err } - if len(data) > maxDownload { + if int64(len(data)) > maxDownload { return nil, fmt.Errorf("%s exceeds %d byte limit", url, maxDownload) } return data, nil @@ -151,7 +180,7 @@ func extractTarGz(archive []byte) ([]byte, error) { return nil, fmt.Errorf("read archive: %w", err) } if isBinaryEntry(hdr.Name) { - return io.ReadAll(io.LimitReader(tr, maxDownload)) + return readCapped(tr) } } return nil, fmt.Errorf("asobi binary not found in archive") @@ -169,7 +198,7 @@ func extractZip(archive []byte) ([]byte, error) { return nil, err } defer rc.Close() - return io.ReadAll(io.LimitReader(rc, maxDownload)) + return readCapped(rc) } } return nil, fmt.Errorf("asobi binary not found in archive") @@ -211,7 +240,9 @@ func replaceExecutable(exe string, bin []byte) error { return fmt.Errorf("move current binary aside: %w", err) } if err := os.Rename(tmpName, exe); err != nil { - _ = os.Rename(old, exe) + if rbErr := os.Rename(old, exe); rbErr != nil { + return fmt.Errorf("install new binary: %w; could not restore original - it is at %s, rename it back to %s manually", err, old, exe) + } return fmt.Errorf("install new binary: %w", err) } return nil diff --git a/internal/update/upgrade_test.go b/internal/update/upgrade_test.go index c306e22..9fc46f8 100644 --- a/internal/update/upgrade_test.go +++ b/internal/update/upgrade_test.go @@ -187,3 +187,29 @@ func TestDownloadVerifyExtractPipeline(t *testing.T) { t.Error("extracted binary mismatch") } } + +func TestExtractBinaryRejectsOversized(t *testing.T) { + old := maxDownload + maxDownload = 8 + defer func() { maxDownload = old }() + + big := bytes.Repeat([]byte("A"), 100) + if _, err := extractBinary(tarGz(t, "asobi", big), "asobi_linux_amd64.tar.gz"); err == nil { + t.Error("oversized extracted binary must be rejected, not silently truncated") + } +} + +func TestDownloadRefusesHTTPDowngrade(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("payload")) + })) + defer target.Close() + redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redir.Close() + + if _, err := download(redir.URL + "/asset"); err == nil { + t.Error("redirect to a non-HTTPS URL must be refused") + } +} From d07bed35225800bf3fa9fe8d7880052e37139906 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Tue, 14 Jul 2026 07:52:21 +0200 Subject: [PATCH 3/3] feat: sign release checksums with ed25519 and verify on upgrade `asobi upgrade` now downloads checksums.txt.sig and verifies it against an embedded ed25519 public key (Go stdlib crypto/ed25519, no new dependency) before trusting checksums.txt. Chain of trust: signature -> checksums.txt -> asset, rooted in the embedded key, so a GitHub or CDN compromise alone can no longer forge an upgrade. Fails closed if the signature is missing, malformed, or does not verify. goreleaser signs checksums.txt via scripts/sign-checksums.sh using the ASOBI_SIGNING_KEY secret (a PEM ed25519 private key). Verified end to end with a local snapshot build: the produced signature verifies against the embedded public key and tampering is rejected. --- .github/workflows/release.yml | 1 + .goreleaser.yaml | 13 +++++++++++++ internal/update/signing.go | 32 +++++++++++++++++++++++++++++++ internal/update/signing_test.go | 34 +++++++++++++++++++++++++++++++++ internal/update/upgrade.go | 7 +++++++ scripts/sign-checksums.sh | 20 +++++++++++++++++++ 6 files changed, 107 insertions(+) create mode 100644 internal/update/signing.go create mode 100644 internal/update/signing_test.go create mode 100755 scripts/sign-checksums.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8974670..4aa512a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,3 +26,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SCOOP_TOKEN: ${{ secrets.SCOOP_TOKEN }} WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + ASOBI_SIGNING_KEY: ${{ secrets.ASOBI_SIGNING_KEY }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 09fc76d..e2779b5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -40,6 +40,19 @@ checksum: name_template: checksums.txt algorithm: sha256 +# Sign checksums.txt with the release ed25519 key (ASOBI_SIGNING_KEY secret, a +# PEM private key). `asobi upgrade` verifies checksums.txt.sig against the +# embedded public key, so a GitHub/CDN compromise alone cannot forge an upgrade. +# The raw signature is produced with openssl and read by Go's crypto/ed25519. +signs: + - id: ed25519 + artifacts: checksum + signature: "${artifact}.sig" + cmd: ./scripts/sign-checksums.sh + args: + - "${artifact}" + - "${signature}" + release: github: owner: widgrensit diff --git a/internal/update/signing.go b/internal/update/signing.go new file mode 100644 index 0000000..e62ec9c --- /dev/null +++ b/internal/update/signing.go @@ -0,0 +1,32 @@ +package update + +import ( + "crypto/ed25519" + "encoding/hex" + "fmt" +) + +// signingPublicKey is the raw 32-byte ed25519 public key whose private half +// signs checksums.txt for every release (held as the ASOBI_SIGNING_KEY CI +// secret, never in the repo). Because the asset is verified against +// checksums.txt and checksums.txt is verified against this key, a GitHub or CDN +// compromise alone cannot forge an upgrade. Rotating the key requires shipping a +// release whose checksums are signed by the new key. Var, not const, so tests +// substitute an ephemeral key. +var signingPublicKey = "d96d1bd8642b31937478086325c9b23c42dacddce866e55654e1e2ebbdf1713a" + +// verifySignature fails closed unless sig is a valid ed25519 signature of +// checksums by the release signing key. +func verifySignature(checksums, sig []byte) error { + pub, err := hex.DecodeString(signingPublicKey) + if err != nil || len(pub) != ed25519.PublicKeySize { + return fmt.Errorf("invalid embedded signing key") + } + if len(sig) != ed25519.SignatureSize { + return fmt.Errorf("checksums signature has wrong size (%d bytes)", len(sig)) + } + if !ed25519.Verify(ed25519.PublicKey(pub), checksums, sig) { + return fmt.Errorf("checksums signature does not verify against the release signing key") + } + return nil +} diff --git a/internal/update/signing_test.go b/internal/update/signing_test.go new file mode 100644 index 0000000..0a27e39 --- /dev/null +++ b/internal/update/signing_test.go @@ -0,0 +1,34 @@ +package update + +import ( + "crypto/ed25519" + "encoding/hex" + "testing" +) + +func TestVerifySignature(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + old := signingPublicKey + signingPublicKey = hex.EncodeToString(pub) + defer func() { signingPublicKey = old }() + + checksums := []byte("abc123 asobi_linux_amd64.tar.gz\n") + sig := ed25519.Sign(priv, checksums) + + if err := verifySignature(checksums, sig); err != nil { + t.Errorf("valid signature rejected: %v", err) + } + if err := verifySignature([]byte("tampered checksums"), sig); err == nil { + t.Error("tampered checksums must fail signature") + } + if err := verifySignature(checksums, sig[:len(sig)-1]); err == nil { + t.Error("wrong-size signature must fail") + } + _, otherPriv, _ := ed25519.GenerateKey(nil) + if err := verifySignature(checksums, ed25519.Sign(otherPriv, checksums)); err == nil { + t.Error("signature from a different key must fail") + } +} diff --git a/internal/update/upgrade.go b/internal/update/upgrade.go index 1c5fca2..874feb0 100644 --- a/internal/update/upgrade.go +++ b/internal/update/upgrade.go @@ -89,6 +89,13 @@ func SelfUpgrade(current string, w io.Writer) error { if err != nil { return fmt.Errorf("download checksums: %w", err) } + sig, err := download(fmt.Sprintf("%s/%s/checksums.txt.sig", releasesBaseURL, latest)) + if err != nil { + return fmt.Errorf("download signature: %w", err) + } + if err := verifySignature(sums, sig); err != nil { + return err + } if err := verifyChecksum(asset, sums, name); err != nil { return err } diff --git a/scripts/sign-checksums.sh b/scripts/sign-checksums.sh new file mode 100755 index 0000000..a52a737 --- /dev/null +++ b/scripts/sign-checksums.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Signs a file with the release ed25519 key (raw signature, verifiable by Go's +# crypto/ed25519). Invoked by goreleaser `signs`. The key arrives as the +# ASOBI_SIGNING_KEY env var (a PEM private key) and is written to a temp file +# that is removed on exit. +set -euo pipefail + +in="$1" +out="$2" + +if [ -z "${ASOBI_SIGNING_KEY:-}" ]; then + echo "sign-checksums: ASOBI_SIGNING_KEY is not set" >&2 + exit 1 +fi + +key="$(mktemp)" +trap 'rm -f "$key"' EXIT +printf '%s' "$ASOBI_SIGNING_KEY" >"$key" + +openssl pkeyutl -sign -inkey "$key" -rawin -in "$in" -out "$out"