From ef60254c8f4cd7851544118858c10a9cafbfe69c Mon Sep 17 00:00:00 2001 From: Jeppe Lillevang Salling Date: Sat, 20 Jun 2026 07:07:37 +0000 Subject: [PATCH 1/8] feat: add self-upgrade subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `agent-init upgrade` to update the binary in place from the latest GitHub release. The check is manual-only — no per-invocation network call. `--check` reports whether a newer version exists; `--dry-run` downloads and verifies without replacing; `--force` reinstalls or upgrades a dev build. The new internal/selfupdate package fetches the latest release, selects the asset matching the running OS/arch, verifies its SHA-256 against the release checksums.txt before installing, and swaps the binary atomically (write-temp-then-rename with a move-aside fallback for platforms that can't rename over a running executable). The install path is resolved via os.Executable (following symlinks); a non-writable install dir fails with a hint rather than attempting privilege escalation. Covers the package with unit tests (version compare, asset/checksum, archive extraction, full upgrade/dry-run/mismatch/force flows via an in-memory Source, and an httptest-backed GitHub client) plus CLI tests for help, the dev-build refusal, and arg validation. Documents the subcommand in docs/cli.md and the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agent/CODEBASE.md | 22 +- README.md | 3 + docs/cli.md | 32 +- internal/cli/cli.go | 72 +++++ internal/cli/cli_test.go | 38 +++ internal/selfupdate/github.go | 122 ++++++++ internal/selfupdate/github_test.go | 71 +++++ internal/selfupdate/selfupdate.go | 410 +++++++++++++++++++++++++ internal/selfupdate/selfupdate_test.go | 362 ++++++++++++++++++++++ 9 files changed, 1123 insertions(+), 9 deletions(-) create mode 100644 internal/selfupdate/github.go create mode 100644 internal/selfupdate/github_test.go create mode 100644 internal/selfupdate/selfupdate.go create mode 100644 internal/selfupdate/selfupdate_test.go diff --git a/.agent/CODEBASE.md b/.agent/CODEBASE.md index d85108d..1f68491 100644 --- a/.agent/CODEBASE.md +++ b/.agent/CODEBASE.md @@ -15,6 +15,7 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ |-- Justfile |-- LICENSE |-- README.md +|-- agent-init |-- cmd | `-- agent-init | `-- main.go @@ -60,6 +61,11 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ | | |-- term_darwin.go | | |-- term_linux.go | | `-- term_other.go +| |-- selfupdate +| | |-- github.go +| | |-- github_test.go +| | |-- selfupdate.go +| | `-- selfupdate_test.go | |-- testflags | | `-- testflags.go | `-- trackers @@ -89,9 +95,9 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ ## Public API surface ### Go -internal/cli/cli.go:66:type Version struct { -internal/cli/cli.go:152:type App struct { -internal/cli/cli.go:160:func New(out, errOut io.Writer, version Version) App { +internal/cli/cli.go:67:type Version struct { +internal/cli/cli.go:170:type App struct { +internal/cli/cli.go:178:func New(out, errOut io.Writer, version Version) App { internal/cli/cli_test.go:15:func TestListFlavors(t *testing.T) { internal/cli/cli_test.go:28:func TestInitLegacyTargetArgument(t *testing.T) { internal/cli/cli_test.go:47:func TestInitLegacyTargetArgumentWithoutFlag(t *testing.T) { @@ -131,8 +137,11 @@ internal/cli/cli_test.go:769:func TestFlaglessSubcommandHelp(t *testing.T) { internal/cli/cli_test.go:787:func TestUnknownCommandErrorPointsAtHelp(t *testing.T) { internal/cli/cli_test.go:799:func TestUnknownFlavorErrorPointsAtHelp(t *testing.T) { internal/cli/cli_test.go:815:func TestHelpFlagsMatchDocs(t *testing.T) { -internal/cli/cli_test.go:842:func TestVersion(t *testing.T) { -internal/cli/cli_test.go:855:func TestVersionDefaultsToDev(t *testing.T) { +internal/cli/cli_test.go:842:func TestUpgradeHelp(t *testing.T) { +internal/cli/cli_test.go:859:func TestUpgradeDevBuildRefusedWithoutForce(t *testing.T) { +internal/cli/cli_test.go:871:func TestUpgradeRejectsPositionalArgs(t *testing.T) { +internal/cli/cli_test.go:880:func TestVersion(t *testing.T) { +internal/cli/cli_test.go:893:func TestVersionDefaultsToDev(t *testing.T) { internal/flavors/claudecowork/flavor.go:11:func Templates() embed.FS { internal/flavors/claudecowork/flavor.go:18:func ExecutablePaths() []string { internal/flavors/claudecowork/flavor.go:25:func NextSteps(target string) string { @@ -186,9 +195,6 @@ internal/gitignore/gitignore_test.go:128:func TestEnsureHiddenCreatesAndAppends( internal/gitignore/gitignore_test.go:174:func TestEnsureHiddenIsIdempotent(t *testing.T) { internal/gitignore/gitignore_test.go:196:func TestEnsureHiddenReplacesStaleBlockInPlace(t *testing.T) { internal/gitignore/gitignore_test.go:234:func TestEnsureHiddenWritesNoGitignore(t *testing.T) { -internal/scaffold/color_test.go:9:func TestColorDisabledForNonTTYOutputs(t *testing.T) { -internal/scaffold/color_test.go:26:func TestColorDisabledByEnvironment(t *testing.T) { -internal/scaffold/color_test.go:50:func TestColorEnabledForTerminalFile(t *testing.T) { ## Stats diff --git a/README.md b/README.md index 8d0cb1b..deed3c9 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,13 @@ agent-init add-tracker agent-init list-flavors agent-init list-trackers agent-init version +agent-init upgrade [--check] [--dry-run] [--force] ``` Defaults: flavor `fullstack`, target `.`. +`agent-init upgrade` replaces the binary in place with the latest GitHub release, verifying its checksum first. There is no automatic update check — run `agent-init upgrade --check` to see if a newer version exists. See [`docs/cli.md`](./docs/cli.md#upgrade). + Run `agent-init --help` for the subcommand list, and `agent-init --help` for a command's flags and examples. See [`docs/cli.md`](./docs/cli.md) for the full reference. Examples. Each block states what the command writes and what it leaves alone. diff --git a/docs/cli.md b/docs/cli.md index 93f5c2f..844d923 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI -`agent-init` is a small CLI with five subcommands. Source: [internal/cli/cli.go](../internal/cli/cli.go). +`agent-init` is a small CLI with six subcommands. Source: [internal/cli/cli.go](../internal/cli/cli.go). ``` agent-init init [flavor] [target-dir] @@ -8,6 +8,7 @@ agent-init add-tracker agent-init list-flavors agent-init list-trackers agent-init version +agent-init upgrade [--check] [--dry-run] [--force] ``` If no subcommand is given, the binary defaults to `init` with the default flavor. So `agent-init` and `agent-init init` are equivalent. @@ -131,6 +132,35 @@ In dev builds (`go run ./cmd/agent-init version`), prints `version=dev commit=dev buildDate=unknown` — the ldflags only apply to release builds. +## `upgrade` + +Updates `agent-init` in place to the latest GitHub release. There is **no +automatic background check**: a release is only contacted when you run this +command, so normal invocations make no network calls. + +```bash +agent-init upgrade # install the latest release, replacing this binary +agent-init upgrade --check # only report whether a newer version exists +agent-init upgrade --dry-run # download and verify, but do not replace the binary +``` + +### Flags + +| Flag | Effect | +|------|--------| +| `--check` | Report whether a newer release exists and exit, without downloading or installing anything. | +| `--dry-run` | Download the latest archive and verify its checksum, but stop before replacing the binary. | +| `--force` | Install the latest release even when the current version is already newest. Also required to upgrade a dev build, which has no release version to compare against. | + +### Behavior + +- Queries `https://api.github.com/repos/Lillevang/agent-init/releases/latest` and compares the embedded `version` (the release tag, e.g. `v1.2.3`) against the latest tag using semver ordering. If `GITHUB_TOKEN` or `GH_TOKEN` is set, it is sent to lift the anonymous rate limit. +- Selects the asset matching the running OS/arch (`agent-init--.tar.gz`, or `.zip` on Windows), downloads it along with `checksums.txt`, and **verifies the archive's SHA-256 against the published checksum before installing**. A mismatch aborts the upgrade and leaves the existing binary untouched. +- Replaces the running binary atomically: the new binary is written to a temp file in the same directory, made executable, then renamed over the target (with a move-aside fallback for platforms that can't rename over a running executable). A failure mid-way never leaves a half-written binary in place. +- Resolves the install location with `os.Executable()` (following symlinks), so it replaces the real binary rather than a symlink to it. If the install directory is not writable (e.g. a root-owned `/usr/local/bin`), the command fails with a hint to re-run with elevated access rather than attempting privilege escalation itself. +- A dev build (`version=dev`) cannot be compared to a release; `upgrade` refuses unless `--force` is passed, which installs the latest release outright. +- Source: [internal/selfupdate/selfupdate.go](../internal/selfupdate/selfupdate.go) (verify + replace), [internal/selfupdate/github.go](../internal/selfupdate/github.go) (releases client), [cli.go:runUpgrade](../internal/cli/cli.go). The release asset names this matches are cut by [.github/workflows/release.yml](../.github/workflows/release.yml). + ## Help The binary documents its own usage. Help text is generated from a single data diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3c69edc..e427872 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -16,6 +16,7 @@ import ( "github.com/Lillevang/agent-init/internal/gitconfig" "github.com/Lillevang/agent-init/internal/gitignore" "github.com/Lillevang/agent-init/internal/scaffold" + "github.com/Lillevang/agent-init/internal/selfupdate" "github.com/Lillevang/agent-init/internal/trackers" ) @@ -138,8 +139,25 @@ var commands = []commandHelp{ summary: "print version info (version + commit + build date)", usage: "agent-init version", }, + { + name: "upgrade", + summary: "download and install the latest release, replacing this binary", + usage: "agent-init upgrade [--check] [--dry-run] [--force]", + flags: []flagHelp{ + {"--check", "report whether a newer release exists without downloading or installing"}, + {"--dry-run", "download and verify the latest release but do not replace the binary"}, + {"--force", "install the latest release even if already up to date (also required to upgrade a dev build)"}, + }, + examples: []string{ + "agent-init upgrade # install the latest release", + "agent-init upgrade --check # only report whether a newer version exists", + }, + }, } +// upgradeRepo is the GitHub repository self-upgrade pulls releases from. +const upgradeRepo = selfupdate.DefaultRepo + func lookupCommand(name string) (commandHelp, bool) { for _, c := range commands { if c.name == name { @@ -188,6 +206,8 @@ func (a App) Run(ctx context.Context, args []string) error { return a.runAddTracker(ctx, args[1:]) case "version": return a.runVersion(args[1:]) + case "upgrade": + return a.runUpgrade(ctx, args[1:]) case "help": // `help ` prints that subcommand's help; bare help is // the top-level overview. (`-h` / `--help` are caught earlier.) @@ -619,6 +639,58 @@ func (a App) runVersion(args []string) error { return nil } +// runUpgrade checks GitHub for a newer release and, unless --check is set, +// downloads it, verifies its SHA-256 against the release checksums, and replaces +// the running binary. There is no automatic background check: the user opts in +// by running this command. A dev build (version=dev) can't be compared to a +// release, so upgrading one requires --force. +func (a App) runUpgrade(ctx context.Context, args []string) error { + if wantsHelp(args) { + cmd, _ := lookupCommand("upgrade") + a.printCommandHelp(cmd) + return nil + } + flags := a.newFlagSet("upgrade") + check := flags.Bool("check", false, "report whether a newer release exists without installing") + dryRun := flags.Bool("dry-run", false, "download and verify but do not replace the binary") + force := flags.Bool("force", false, "install even if up to date; required to upgrade a dev build") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("usage: agent-init upgrade [--check] [--dry-run] [--force]\nRun 'agent-init upgrade --help' for usage") + } + + updater := selfupdate.NewUpdater(selfupdate.NewGitHubSource(upgradeRepo), a.out) + current := a.version.Version + + if *check { + res, err := updater.Check(ctx, current) + if err != nil { + return err + } + if res.NewerAvailable { + _, _ = fmt.Fprintf(a.out, "A newer version is available: %s (current %s).\nRun 'agent-init upgrade' to install it.\n", res.Latest, current) + } else { + _, _ = fmt.Fprintf(a.out, "agent-init is up to date (%s).\n", current) + } + return nil + } + + if current == "dev" && !*force { + return fmt.Errorf("refusing to upgrade a dev build (version=dev): a dev build has no release version to compare against; pass --force to install the latest release anyway") + } + + return updater.Upgrade(ctx, selfupdate.UpgradeOptions{ + Current: current, + Force: *force, + DryRun: *dryRun, + }) +} + // printHelp renders the top-level overview from the commands table so the // subcommand list never drifts from what the binary actually dispatches. func (a App) printHelp() { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 11865ed..a0be0a2 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -839,6 +839,44 @@ func TestHelpFlagsMatchDocs(t *testing.T) { } } +func TestUpgradeHelp(t *testing.T) { + t.Parallel() + var out bytes.Buffer + app := cli.New(&out, &bytes.Buffer{}, cli.Version{}) + if err := app.Run(context.Background(), []string{"upgrade", "--help"}); err != nil { + t.Fatalf("Run(upgrade --help) error = %v", err) + } + for _, want := range []string{"upgrade", "--check", "--dry-run", "--force", "Examples"} { + if !strings.Contains(out.String(), want) { + t.Errorf("upgrade --help missing %q:\n%s", want, out.String()) + } + } +} + +// A dev build has no release version to compare against, so `upgrade` without +// --force must refuse before making any network call. This keeps the test +// hermetic. +func TestUpgradeDevBuildRefusedWithoutForce(t *testing.T) { + t.Parallel() + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{Version: "dev"}) + err := app.Run(context.Background(), []string{"upgrade"}) + if err == nil { + t.Fatal("Run(upgrade) on dev build = nil, want error") + } + if !strings.Contains(err.Error(), "--force") { + t.Errorf("dev-build refusal should mention --force; got: %v", err) + } +} + +func TestUpgradeRejectsPositionalArgs(t *testing.T) { + t.Parallel() + app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{Version: "v1.0.0"}) + err := app.Run(context.Background(), []string{"upgrade", "extra-arg"}) + if err == nil { + t.Fatal("Run(upgrade extra-arg) = nil, want usage error") + } +} + func TestVersion(t *testing.T) { t.Parallel() var out bytes.Buffer diff --git a/internal/selfupdate/github.go b/internal/selfupdate/github.go new file mode 100644 index 0000000..897d264 --- /dev/null +++ b/internal/selfupdate/github.go @@ -0,0 +1,122 @@ +package selfupdate + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// maxDownloadBytes caps a single asset download. The release archives are a few +// MB; this is a generous ceiling that stops a misbehaving or hostile endpoint +// from streaming unbounded data into memory. +const maxDownloadBytes = 200 << 20 // 200 MiB + +// GitHubSource implements Source against the GitHub releases REST API. +type GitHubSource struct { + Repo string // "owner/name" + APIBaseURL string // default "https://api.github.com" + HTTPClient *http.Client + // Token is sent as a bearer credential to lift the unauthenticated rate + // limit. Optional; release assets are public. + Token string +} + +// NewGitHubSource returns a source for repo, reading an optional token from +// GITHUB_TOKEN or GH_TOKEN so authenticated users avoid the low anonymous rate +// limit. +func NewGitHubSource(repo string) *GitHubSource { + return &GitHubSource{ + Repo: repo, + APIBaseURL: "https://api.github.com", + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + Token: firstEnv("GITHUB_TOKEN", "GH_TOKEN"), + } +} + +func (g *GitHubSource) client() *http.Client { + if g.HTTPClient != nil { + return g.HTTPClient + } + return http.DefaultClient +} + +// Latest fetches the repo's latest published release. +func (g *GitHubSource) Latest(ctx context.Context) (Release, error) { + url := fmt.Sprintf("%s/repos/%s/releases/latest", strings.TrimRight(g.APIBaseURL, "/"), g.Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Release{}, fmt.Errorf("building request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + if g.Token != "" { + req.Header.Set("Authorization", "Bearer "+g.Token) + } + resp, err := g.client().Do(req) + if err != nil { + return Release{}, fmt.Errorf("requesting latest release: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return Release{}, fmt.Errorf("github API %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + var payload struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return Release{}, fmt.Errorf("decoding release: %w", err) + } + if payload.TagName == "" { + return Release{}, fmt.Errorf("github API returned a release with no tag") + } + rel := Release{Tag: payload.TagName} + for _, a := range payload.Assets { + rel.Assets = append(rel.Assets, Asset{Name: a.Name, URL: a.URL}) + } + return rel, nil +} + +// Download fetches the bytes at url, capped at maxDownloadBytes. +func (g *GitHubSource) Download(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + if g.Token != "" { + req.Header.Set("Authorization", "Bearer "+g.Token) + } + resp, err := g.client().Do(req) + if err != nil { + return nil, fmt.Errorf("requesting %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downloading %s: %s", url, resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadBytes+1)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", url, err) + } + if len(data) > maxDownloadBytes { + return nil, fmt.Errorf("download from %s exceeds %d bytes", url, maxDownloadBytes) + } + return data, nil +} + +func firstEnv(keys ...string) string { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" +} diff --git a/internal/selfupdate/github_test.go b/internal/selfupdate/github_test.go new file mode 100644 index 0000000..df8f7b7 --- /dev/null +++ b/internal/selfupdate/github_test.go @@ -0,0 +1,71 @@ +package selfupdate + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGitHubSourceLatest(t *testing.T) { + t.Parallel() + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "tag_name": "v3.1.4", + "assets": [ + {"name": "agent-init-linux-amd64.tar.gz", "browser_download_url": "https://dl.test/a.tar.gz"}, + {"name": "checksums.txt", "browser_download_url": "https://dl.test/checksums.txt"} + ] + }`)) + })) + defer srv.Close() + + g := &GitHubSource{Repo: "Lillevang/agent-init", APIBaseURL: srv.URL, HTTPClient: srv.Client()} + rel, err := g.Latest(context.Background()) + if err != nil { + t.Fatalf("Latest error = %v", err) + } + if gotPath != "/repos/Lillevang/agent-init/releases/latest" { + t.Errorf("requested path = %q", gotPath) + } + if rel.Tag != "v3.1.4" { + t.Errorf("tag = %q, want v3.1.4", rel.Tag) + } + if len(rel.Assets) != 2 || rel.Assets[0].Name != "agent-init-linux-amd64.tar.gz" { + t.Errorf("assets = %+v", rel.Assets) + } +} + +func TestGitHubSourceLatestNon200(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + g := &GitHubSource{Repo: "x/y", APIBaseURL: srv.URL, HTTPClient: srv.Client()} + if _, err := g.Latest(context.Background()); err == nil { + t.Fatal("Latest(404) = nil, want error") + } +} + +func TestGitHubSourceDownload(t *testing.T) { + t.Parallel() + want := []byte("archive-bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(want) + })) + defer srv.Close() + + g := &GitHubSource{Repo: "x/y", APIBaseURL: srv.URL, HTTPClient: srv.Client()} + got, err := g.Download(context.Background(), srv.URL+"/asset") + if err != nil { + t.Fatalf("Download error = %v", err) + } + if string(got) != string(want) { + t.Errorf("Download = %q, want %q", got, want) + } +} diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..55369e4 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,410 @@ +// Package selfupdate checks GitHub for newer agent-init releases and replaces +// the running binary in place. It is the engine behind `agent-init upgrade`. +// +// The flow is deliberately conservative: a download is never installed without +// first verifying its SHA-256 against the release's checksums.txt, and the +// binary is swapped atomically (write-temp-then-rename) so a failure can't leave +// a half-written executable on disk. +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +// DefaultRepo is the GitHub "owner/name" releases are pulled from. +const DefaultRepo = "Lillevang/agent-init" + +// Release is the subset of a GitHub release this package consumes. +type Release struct { + Tag string + Assets []Asset +} + +// Asset is one downloadable file attached to a release. +type Asset struct { + Name string + URL string +} + +// Source fetches release metadata and downloads release assets. It is the seam +// the CLI fills with a GitHub client and tests fill with an in-memory fake. +type Source interface { + Latest(ctx context.Context) (Release, error) + Download(ctx context.Context, url string) ([]byte, error) +} + +// Updater drives a check or an upgrade against a Source. GOOS/GOARCH and ExePath +// are fields rather than hard-coded calls so tests can target arbitrary +// platforms and a throwaway file instead of the real executable. +type Updater struct { + Source Source + Out io.Writer + GOOS string + GOARCH string + ExePath func() (string, error) +} + +// NewUpdater returns an Updater wired to the current platform and the running +// executable's path. +func NewUpdater(src Source, out io.Writer) *Updater { + return &Updater{ + Source: src, + Out: out, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + ExePath: defaultExePath, + } +} + +// CheckResult reports the outcome of a version check. +type CheckResult struct { + Current string + Latest string + NewerAvailable bool +} + +// Check fetches the latest release and compares it to current without +// downloading anything. +func (u *Updater) Check(ctx context.Context, current string) (CheckResult, error) { + rel, err := u.Source.Latest(ctx) + if err != nil { + return CheckResult{}, fmt.Errorf("fetching latest release: %w", err) + } + return CheckResult{ + Current: current, + Latest: rel.Tag, + NewerAvailable: compareVersions(current, rel.Tag) < 0, + }, nil +} + +// UpgradeOptions tunes an upgrade run. +type UpgradeOptions struct { + Current string + // Force installs the latest release even when current is already newest (or + // unparseable, e.g. a dev build). + Force bool + // DryRun downloads and verifies the release but stops before replacing the + // binary. + DryRun bool +} + +// Upgrade fetches the latest release, verifies its checksum, and atomically +// replaces the running binary. It is a no-op (with a notice) when the current +// version is already the newest and Force is unset. +func (u *Updater) Upgrade(ctx context.Context, opts UpgradeOptions) error { + rel, err := u.Source.Latest(ctx) + if err != nil { + return fmt.Errorf("fetching latest release: %w", err) + } + if !opts.Force && compareVersions(opts.Current, rel.Tag) >= 0 { + _, _ = fmt.Fprintf(u.Out, "agent-init is already up to date (%s).\n", opts.Current) + return nil + } + + assetName := u.assetName() + asset, ok := findAsset(rel.Assets, assetName) + if !ok { + return fmt.Errorf("release %s has no asset for %s/%s (expected %q)", rel.Tag, u.GOOS, u.GOARCH, assetName) + } + sums, ok := findAsset(rel.Assets, "checksums.txt") + if !ok { + return fmt.Errorf("release %s has no checksums.txt; refusing to install an unverified binary", rel.Tag) + } + + _, _ = fmt.Fprintf(u.Out, "Downloading %s (%s)...\n", assetName, rel.Tag) + archive, err := u.Source.Download(ctx, asset.URL) + if err != nil { + return fmt.Errorf("downloading %s: %w", assetName, err) + } + sumData, err := u.Source.Download(ctx, sums.URL) + if err != nil { + return fmt.Errorf("downloading checksums.txt: %w", err) + } + if err := verifyChecksum(assetName, archive, sumData); err != nil { + return err + } + + bin, err := extractBinary(archive, assetName, u.binaryName()) + if err != nil { + return fmt.Errorf("extracting %s: %w", u.binaryName(), err) + } + + target, err := u.resolveExePath() + if err != nil { + return err + } + if opts.DryRun { + _, _ = fmt.Fprintf(u.Out, "Verified %s. Would replace %s (dry-run).\n", assetName, target) + return nil + } + if err := replaceBinary(target, bin); err != nil { + return err + } + _, _ = fmt.Fprintf(u.Out, "Upgraded %s -> %s (%s).\n", opts.Current, rel.Tag, target) + return nil +} + +// assetName is the release archive for this platform, matching the names cut by +// the release workflow (agent-init--.tar.gz, .zip on Windows). +func (u *Updater) assetName() string { + base := fmt.Sprintf("agent-init-%s-%s", u.GOOS, u.GOARCH) + if u.GOOS == "windows" { + return base + ".zip" + } + return base + ".tar.gz" +} + +// binaryName is the executable file packed inside the archive. +func (u *Updater) binaryName() string { + base := fmt.Sprintf("agent-init-%s-%s", u.GOOS, u.GOARCH) + if u.GOOS == "windows" { + return base + ".exe" + } + return base +} + +func (u *Updater) resolveExePath() (string, error) { + fn := u.ExePath + if fn == nil { + fn = defaultExePath + } + return fn() +} + +func defaultExePath() (string, error) { + p, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locating current executable: %w", err) + } + // Resolve symlinks so we replace the real binary, not a symlink pointing at + // it. If resolution fails, fall back to the reported path. + if resolved, err := filepath.EvalSymlinks(p); err == nil { + return resolved, nil + } + return p, nil +} + +func findAsset(assets []Asset, name string) (Asset, bool) { + for _, a := range assets { + if a.Name == name { + return a, true + } + } + return Asset{}, false +} + +// verifyChecksum confirms data hashes to the entry for name in a sha256sum-style +// checksums file. Lines look like "␣␣"; the binary-mode "*name" +// prefix is tolerated. +func verifyChecksum(name string, data, sums []byte) error { + want := "" + for _, line := range strings.Split(string(sums), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + if fields[1] == name || fields[1] == "*"+name { + want = strings.ToLower(fields[0]) + break + } + } + if want == "" { + return fmt.Errorf("checksums.txt has no entry for %s", name) + } + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if got != want { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", name, got, want) + } + return nil +} + +func extractBinary(archive []byte, assetName, binName string) ([]byte, error) { + if strings.HasSuffix(assetName, ".zip") { + return extractFromZip(archive, binName) + } + return extractFromTarGz(archive, binName) +} + +func extractFromTarGz(archive []byte, binName string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("opening gzip: %w", err) + } + defer func() { _ = gz.Close() }() + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("reading tar: %w", err) + } + if filepath.Base(hdr.Name) == binName { + return io.ReadAll(tr) + } + } + return nil, fmt.Errorf("%s not found in archive", binName) +} + +func extractFromZip(archive []byte, binName string) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("opening zip: %w", err) + } + for _, f := range zr.File { + if filepath.Base(f.Name) == binName { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("opening %s in zip: %w", binName, err) + } + defer func() { _ = rc.Close() }() + return io.ReadAll(rc) + } + } + return nil, fmt.Errorf("%s not found in archive", binName) +} + +// replaceBinary installs data at target atomically. It writes a temp file in the +// same directory (so the final rename stays on one filesystem), then renames it +// over the target. When a direct rename fails — Windows can't replace a running +// executable — it moves the old binary aside first and rolls back on error. +func replaceBinary(target string, data []byte) error { + dir := filepath.Dir(target) + tmp, err := os.CreateTemp(dir, ".agent-init-upgrade-*") + if err != nil { + return fmt.Errorf("creating temp file in %s: %w (the install directory may need elevated write access)", dir, err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing new binary: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("finalizing new binary: %w", err) + } + if err := os.Chmod(tmpName, 0o755); err != nil { + return fmt.Errorf("setting permissions on new binary: %w", err) + } + + if err := os.Rename(tmpName, target); err == nil { + return nil + } + // Fallback: move the existing binary aside, then move the new one in. + old := target + ".old" + _ = os.Remove(old) + if err := os.Rename(target, old); err != nil { + return fmt.Errorf("replacing %s: %w (the install directory may need elevated write access)", target, err) + } + if err := os.Rename(tmpName, target); err != nil { + _ = os.Rename(old, target) // roll back + return fmt.Errorf("installing new binary at %s: %w", target, err) + } + _ = os.Remove(old) + return nil +} + +// compareVersions orders two version strings as semver (-1, 0, 1). A version +// that doesn't parse (e.g. "dev") is treated as older than any real release, so +// a dev build always sees a release as newer. +func compareVersions(a, b string) int { + pa, oka := parseSemver(a) + pb, okb := parseSemver(b) + switch { + case oka && okb: + return pa.compare(pb) + case oka && !okb: + return 1 + case !oka && okb: + return -1 + default: + return strings.Compare(a, b) + } +} + +type semver struct { + major, minor, patch int + pre string +} + +// parseSemver accepts vX, vX.Y, vX.Y.Z (with optional leading v and optional +// -prerelease / +build suffixes). Anything else fails to parse. +func parseSemver(s string) (semver, bool) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + s = strings.TrimPrefix(s, "V") + if s == "" { + return semver{}, false + } + if i := strings.IndexByte(s, '+'); i >= 0 { + s = s[:i] + } + pre := "" + if i := strings.IndexByte(s, '-'); i >= 0 { + pre = s[i+1:] + s = s[:i] + } + parts := strings.Split(s, ".") + if len(parts) == 0 || len(parts) > 3 { + return semver{}, false + } + var nums [3]int + for i := range parts { + n, err := strconv.Atoi(parts[i]) + if err != nil || n < 0 { + return semver{}, false + } + nums[i] = n + } + return semver{nums[0], nums[1], nums[2], pre}, true +} + +func (a semver) compare(b semver) int { + if c := cmpInt(a.major, b.major); c != 0 { + return c + } + if c := cmpInt(a.minor, b.minor); c != 0 { + return c + } + if c := cmpInt(a.patch, b.patch); c != 0 { + return c + } + // A release outranks a prerelease of the same core version. + switch { + case a.pre == "" && b.pre == "": + return 0 + case a.pre == "": + return 1 + case b.pre == "": + return -1 + default: + return strings.Compare(a.pre, b.pre) + } +} + +func cmpInt(a, b int) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..9d424c9 --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,362 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "testing" +) + +// fakeSource serves a fixed release and a URL->bytes map for downloads. +type fakeSource struct { + rel Release + files map[string][]byte + err error +} + +func (f *fakeSource) Latest(_ context.Context) (Release, error) { return f.rel, f.err } + +func (f *fakeSource) Download(_ context.Context, url string) ([]byte, error) { + b, ok := f.files[url] + if !ok { + return nil, fmt.Errorf("no file at %s", url) + } + return b, nil +} + +func TestCompareVersions(t *testing.T) { + t.Parallel() + cases := []struct { + a, b string + want int + }{ + {"v1.0.0", "v1.0.1", -1}, + {"v1.2.0", "v1.1.9", 1}, + {"v1.2.3", "v1.2.3", 0}, + {"1.2.3", "v1.2.3", 0}, // leading v optional + {"v2.0.0", "v1.9.9", 1}, + {"v1.0.0", "v1.0", 0}, // missing patch == .0 + {"v1.0.0-rc1", "v1.0.0", -1}, // prerelease < release + {"v1.0.0", "v1.0.0-rc1", 1}, // release > prerelease + {"v1.0.0+build", "v1.0.0", 0}, // build metadata ignored + {"dev", "v1.0.0", -1}, // unparseable current is older + {"v1.0.0", "dev", 1}, // unparseable latest is older + } + for _, tc := range cases { + if got := compareVersions(tc.a, tc.b); got != tc.want { + t.Errorf("compareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) + } + } +} + +func TestAssetAndBinaryNames(t *testing.T) { + t.Parallel() + cases := []struct { + goos, goarch string + wantAsset, wantBin string + }{ + {"linux", "amd64", "agent-init-linux-amd64.tar.gz", "agent-init-linux-amd64"}, + {"darwin", "arm64", "agent-init-darwin-arm64.tar.gz", "agent-init-darwin-arm64"}, + {"windows", "amd64", "agent-init-windows-amd64.zip", "agent-init-windows-amd64.exe"}, + } + for _, tc := range cases { + u := &Updater{GOOS: tc.goos, GOARCH: tc.goarch} + if got := u.assetName(); got != tc.wantAsset { + t.Errorf("assetName(%s/%s) = %q, want %q", tc.goos, tc.goarch, got, tc.wantAsset) + } + if got := u.binaryName(); got != tc.wantBin { + t.Errorf("binaryName(%s/%s) = %q, want %q", tc.goos, tc.goarch, got, tc.wantBin) + } + } +} + +func TestVerifyChecksum(t *testing.T) { + t.Parallel() + data := []byte("the archive bytes") + sum := sha256.Sum256(data) + good := fmt.Sprintf("%s agent-init-linux-amd64.tar.gz\n", hex.EncodeToString(sum[:])) + + if err := verifyChecksum("agent-init-linux-amd64.tar.gz", data, []byte(good)); err != nil { + t.Errorf("verifyChecksum(matching) = %v, want nil", err) + } + // Tolerate the sha256sum binary-mode "*" prefix. + star := fmt.Sprintf("%s *agent-init-linux-amd64.tar.gz\n", hex.EncodeToString(sum[:])) + if err := verifyChecksum("agent-init-linux-amd64.tar.gz", data, []byte(star)); err != nil { + t.Errorf("verifyChecksum(star prefix) = %v, want nil", err) + } + if err := verifyChecksum("agent-init-linux-amd64.tar.gz", []byte("tampered"), []byte(good)); err == nil { + t.Error("verifyChecksum(mismatch) = nil, want error") + } + if err := verifyChecksum("missing.tar.gz", data, []byte(good)); err == nil { + t.Error("verifyChecksum(no entry) = nil, want error") + } +} + +func makeTarGz(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.Fatalf("tar header: %v", err) + } + if _, err := tw.Write(content); err != nil { + t.Fatalf("tar write: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return buf.Bytes() +} + +func makeZip(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create(name) + if err != nil { + t.Fatalf("zip create: %v", err) + } + if _, err := w.Write(content); err != nil { + t.Fatalf("zip write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + return buf.Bytes() +} + +func TestExtractBinary(t *testing.T) { + t.Parallel() + want := []byte("new binary contents") + + gz := makeTarGz(t, "agent-init-linux-amd64", want) + got, err := extractBinary(gz, "agent-init-linux-amd64.tar.gz", "agent-init-linux-amd64") + if err != nil { + t.Fatalf("extractBinary(tar.gz) error = %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("tar.gz extract = %q, want %q", got, want) + } + + z := makeZip(t, "agent-init-windows-amd64.exe", want) + got, err = extractBinary(z, "agent-init-windows-amd64.zip", "agent-init-windows-amd64.exe") + if err != nil { + t.Fatalf("extractBinary(zip) error = %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("zip extract = %q, want %q", got, want) + } + + if _, err := extractBinary(gz, "agent-init-linux-amd64.tar.gz", "not-present"); err == nil { + t.Error("extractBinary(missing entry) = nil, want error") + } +} + +// buildRelease wires a fakeSource for linux/amd64 with a valid archive and +// checksums file for the given tag and binary content. +func buildRelease(t *testing.T, tag string, binContent []byte) *fakeSource { + t.Helper() + archive := makeTarGz(t, "agent-init-linux-amd64", binContent) + sum := sha256.Sum256(archive) + checksums := fmt.Sprintf("%s agent-init-linux-amd64.tar.gz\n", hex.EncodeToString(sum[:])) + const archiveURL = "https://example.test/agent-init-linux-amd64.tar.gz" + const sumURL = "https://example.test/checksums.txt" + return &fakeSource{ + rel: Release{ + Tag: tag, + Assets: []Asset{ + {Name: "agent-init-linux-amd64.tar.gz", URL: archiveURL}, + {Name: "checksums.txt", URL: sumURL}, + }, + }, + files: map[string][]byte{ + archiveURL: archive, + sumURL: []byte(checksums), + }, + } +} + +func newTestUpdater(src Source, out *bytes.Buffer, target string) *Updater { + return &Updater{ + Source: src, + Out: out, + GOOS: "linux", + GOARCH: "amd64", + ExePath: func() (string, error) { return target, nil }, + } +} + +func TestUpgradeReplacesBinary(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("OLD BINARY"), 0o755); err != nil { + t.Fatal(err) + } + newBin := []byte("BRAND NEW BINARY") + src := buildRelease(t, "v2.0.0", newBin) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err != nil { + t.Fatalf("Upgrade error = %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, newBin) { + t.Errorf("binary after upgrade = %q, want %q", got, newBin) + } + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o100 == 0 { + t.Errorf("upgraded binary is not executable: mode %v", info.Mode()) + } +} + +func TestUpgradeAlreadyUpToDate(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("CURRENT"), 0o755); err != nil { + t.Fatal(err) + } + src := buildRelease(t, "v1.0.0", []byte("SHOULD NOT INSTALL")) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err != nil { + t.Fatalf("Upgrade error = %v", err) + } + got, _ := os.ReadFile(target) + if string(got) != "CURRENT" { + t.Errorf("binary changed when already up to date: %q", got) + } + if !bytes.Contains(out.Bytes(), []byte("up to date")) { + t.Errorf("expected up-to-date notice, got %q", out.String()) + } +} + +func TestUpgradeForceReinstallsSameVersion(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + newBin := []byte("REINSTALLED") + src := buildRelease(t, "v1.0.0", newBin) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0", Force: true}); err != nil { + t.Fatalf("Upgrade(force) error = %v", err) + } + got, _ := os.ReadFile(target) + if !bytes.Equal(got, newBin) { + t.Errorf("force did not reinstall: %q", got) + } +} + +func TestUpgradeDryRunDoesNotReplace(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + src := buildRelease(t, "v2.0.0", []byte("NEW")) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0", DryRun: true}); err != nil { + t.Fatalf("Upgrade(dry-run) error = %v", err) + } + got, _ := os.ReadFile(target) + if string(got) != "OLD" { + t.Errorf("dry-run replaced the binary: %q", got) + } + if !bytes.Contains(out.Bytes(), []byte("dry-run")) { + t.Errorf("expected dry-run notice, got %q", out.String()) + } +} + +func TestUpgradeChecksumMismatchLeavesBinary(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "agent-init") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + src := buildRelease(t, "v2.0.0", []byte("NEW")) + // Corrupt the published checksum so verification fails. + src.files["https://example.test/checksums.txt"] = []byte("deadbeef agent-init-linux-amd64.tar.gz\n") + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}) + if err == nil { + t.Fatal("Upgrade(bad checksum) = nil, want error") + } + got, _ := os.ReadFile(target) + if string(got) != "OLD" { + t.Errorf("checksum failure replaced the binary: %q", got) + } +} + +func TestUpgradeNoAssetForPlatform(t *testing.T) { + t.Parallel() + src := buildRelease(t, "v2.0.0", []byte("NEW")) // only has linux/amd64 + var out bytes.Buffer + u := newTestUpdater(src, &out, filepath.Join(t.TempDir(), "agent-init")) + u.GOOS = "plan9" + u.GOARCH = "mips" + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err == nil { + t.Fatal("Upgrade(no matching asset) = nil, want error") + } +} + +func TestUpgradeMissingChecksums(t *testing.T) { + t.Parallel() + src := buildRelease(t, "v2.0.0", []byte("NEW")) + // Drop the checksums asset entirely. + src.rel.Assets = src.rel.Assets[:1] + var out bytes.Buffer + u := newTestUpdater(src, &out, filepath.Join(t.TempDir(), "agent-init")) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err == nil { + t.Fatal("Upgrade(no checksums.txt) = nil, want error") + } +} + +func TestCheck(t *testing.T) { + t.Parallel() + src := buildRelease(t, "v2.0.0", []byte("NEW")) + u := newTestUpdater(src, &bytes.Buffer{}, "") + + res, err := u.Check(context.Background(), "v1.0.0") + if err != nil { + t.Fatalf("Check error = %v", err) + } + if !res.NewerAvailable || res.Latest != "v2.0.0" { + t.Errorf("Check = %+v, want NewerAvailable and Latest v2.0.0", res) + } + + res, err = u.Check(context.Background(), "v2.0.0") + if err != nil { + t.Fatalf("Check error = %v", err) + } + if res.NewerAvailable { + t.Errorf("Check(current==latest) reported newer available") + } +} From 553926e6807d2b0497b7668787ae867dbbfb56b3 Mon Sep 17 00:00:00 2001 From: Jeppe Lillevang Salling Date: Sun, 21 Jun 2026 12:15:36 +0000 Subject: [PATCH 2/8] fix(selfupdate): cap archive-entry size to prevent decompression DoS The archive download was capped at 200 MiB, but the per-entry read of the tar.gz/zip payload was unbounded. A release-pipeline-compromise (gzip/zip bomb) could decompress to many GB and OOM the upgrading user. Trust root (verified SHA-256 checksum from the same release) bounds exploitability; this is defense-in-depth. Wraps the per-entry io.ReadAll with io.LimitReader against a 64 MiB ceiling (~10x the shipped binary). Truncation is treated as an explicit error rather than a silent partial extract. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/selfupdate/selfupdate.go | 23 +++++++++++++++++++++-- internal/selfupdate/selfupdate_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index 55369e4..c4a78af 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -27,6 +27,11 @@ import ( // DefaultRepo is the GitHub "owner/name" releases are pulled from. const DefaultRepo = "Lillevang/agent-init" +// maxBinaryBytes caps each archive-entry read so a release-pipeline-compromise +// scenario (gzip/zip bomb) cannot OOM/disk-DoS the upgrading user. The shipped +// binary is single-digit MiB; 64 MiB is ~10x headroom for plausible growth. +const maxBinaryBytes = 64 << 20 // 64 MiB + // Release is the subset of a GitHub release this package consumes. type Release struct { Tag string @@ -255,7 +260,7 @@ func extractFromTarGz(archive []byte, binName string) ([]byte, error) { return nil, fmt.Errorf("reading tar: %w", err) } if filepath.Base(hdr.Name) == binName { - return io.ReadAll(tr) + return readCapped(tr, binName) } } return nil, fmt.Errorf("%s not found in archive", binName) @@ -273,12 +278,26 @@ func extractFromZip(archive []byte, binName string) ([]byte, error) { return nil, fmt.Errorf("opening %s in zip: %w", binName, err) } defer func() { _ = rc.Close() }() - return io.ReadAll(rc) + return readCapped(rc, binName) } } return nil, fmt.Errorf("%s not found in archive", binName) } +// readCapped reads up to maxBinaryBytes from r. If the entry is larger it +// returns an explicit error instead of silently truncating, so a gzip/zip-bomb +// release archive can't slip past extraction as a partial binary. +func readCapped(r io.Reader, name string) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxBinaryBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxBinaryBytes { + return nil, fmt.Errorf("%s exceeds %d bytes uncompressed; refusing to extract", name, maxBinaryBytes) + } + return data, nil +} + // replaceBinary installs data at target atomically. It writes a temp file in the // same directory (so the final rename stays on one filesystem), then renames it // over the target. When a direct rename fails — Windows can't replace a running diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index 9d424c9..1403074 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" ) @@ -339,6 +340,31 @@ func TestUpgradeMissingChecksums(t *testing.T) { } } +// TestExtractBinaryRejectsOversizedEntry asserts that an archive entry whose +// uncompressed payload exceeds maxBinaryBytes errors out instead of being +// allocated wholesale — the defense-in-depth check against a gzip/zip-bomb +// release archive. +func TestExtractBinaryRejectsOversizedEntry(t *testing.T) { + t.Parallel() + // One byte over the cap; the bytes themselves are zeros so the gzip/zip + // blob stays tiny (highly compressible) and the test stays fast. + oversized := bytes.Repeat([]byte{0}, maxBinaryBytes+1) + + gz := makeTarGz(t, "agent-init-linux-amd64", oversized) + if _, err := extractBinary(gz, "agent-init-linux-amd64.tar.gz", "agent-init-linux-amd64"); err == nil { + t.Error("extractBinary(oversized tar.gz) = nil, want error") + } else if !strings.Contains(err.Error(), "exceeds") { + t.Errorf("extractBinary(oversized tar.gz) error = %v, want exceeds-bytes error", err) + } + + z := makeZip(t, "agent-init-windows-amd64.exe", oversized) + if _, err := extractBinary(z, "agent-init-windows-amd64.zip", "agent-init-windows-amd64.exe"); err == nil { + t.Error("extractBinary(oversized zip) = nil, want error") + } else if !strings.Contains(err.Error(), "exceeds") { + t.Errorf("extractBinary(oversized zip) error = %v, want exceeds-bytes error", err) + } +} + func TestCheck(t *testing.T) { t.Parallel() src := buildRelease(t, "v2.0.0", []byte("NEW")) From bb8e28e44054e0da29f9cca629cf59999c77cd6a Mon Sep 17 00:00:00 2001 From: Jeppe Lillevang Salling Date: Sun, 21 Jun 2026 12:17:00 +0000 Subject: [PATCH 3/8] refactor(selfupdate): use golang.org/x/mod/semver instead of in-package impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-rolled parseSemver/compareVersions/cmpInt (and the 11-row table that tested them) with golang.org/x/mod/semver.Compare, which already implements the full semver ordering and is the standard Go module for this. A small normalizeVersion wrapper preserves the existing caller contract (leading v optional, missing minor/patch fill to .0, "dev" treated as older than any release per semver's invalid-less-than-valid rule). Net subtraction of ~55 LoC. Trims the table test to the cases that exercise our normalization seam — semver-internal ordering is upstream's job. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 2 + go.sum | 2 + internal/selfupdate/selfupdate.go | 96 +++++--------------------- internal/selfupdate/selfupdate_test.go | 17 +++-- 4 files changed, 29 insertions(+), 88 deletions(-) create mode 100644 go.sum diff --git a/go.mod b/go.mod index d8734e4..b9818a4 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module github.com/Lillevang/agent-init go 1.26 toolchain go1.26.3 + +require golang.org/x/mod v0.37.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9e6a024 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index c4a78af..23f2cb4 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -20,8 +20,9 @@ import ( "os" "path/filepath" "runtime" - "strconv" "strings" + + "golang.org/x/mod/semver" ) // DefaultRepo is the GitHub "owner/name" releases are pulled from. @@ -341,89 +342,26 @@ func replaceBinary(target string, data []byte) error { // compareVersions orders two version strings as semver (-1, 0, 1). A version // that doesn't parse (e.g. "dev") is treated as older than any real release, so -// a dev build always sees a release as newer. +// a dev build always sees a release as newer. Delegates to golang.org/x/mod/semver +// after normalizing the leading v and filling in missing .MINOR/.PATCH so +// inputs like "1.2.3" or "v1.0" still compare cleanly. func compareVersions(a, b string) int { - pa, oka := parseSemver(a) - pb, okb := parseSemver(b) - switch { - case oka && okb: - return pa.compare(pb) - case oka && !okb: - return 1 - case !oka && okb: - return -1 - default: - return strings.Compare(a, b) - } + return semver.Compare(normalizeVersion(a), normalizeVersion(b)) } -type semver struct { - major, minor, patch int - pre string -} - -// parseSemver accepts vX, vX.Y, vX.Y.Z (with optional leading v and optional -// -prerelease / +build suffixes). Anything else fails to parse. -func parseSemver(s string) (semver, bool) { +// normalizeVersion makes a best-effort canonical "vMAJOR.MINOR.PATCH" string +// for input that may omit the leading v or trailing components. An empty +// return means the input is not a valid semver; semver.Compare then treats it +// as less than any valid version (per its "invalid < valid" contract). +func normalizeVersion(s string) string { s = strings.TrimSpace(s) - s = strings.TrimPrefix(s, "v") - s = strings.TrimPrefix(s, "V") if s == "" { - return semver{}, false - } - if i := strings.IndexByte(s, '+'); i >= 0 { - s = s[:i] - } - pre := "" - if i := strings.IndexByte(s, '-'); i >= 0 { - pre = s[i+1:] - s = s[:i] - } - parts := strings.Split(s, ".") - if len(parts) == 0 || len(parts) > 3 { - return semver{}, false - } - var nums [3]int - for i := range parts { - n, err := strconv.Atoi(parts[i]) - if err != nil || n < 0 { - return semver{}, false - } - nums[i] = n + return "" } - return semver{nums[0], nums[1], nums[2], pre}, true -} - -func (a semver) compare(b semver) int { - if c := cmpInt(a.major, b.major); c != 0 { - return c - } - if c := cmpInt(a.minor, b.minor); c != 0 { - return c - } - if c := cmpInt(a.patch, b.patch); c != 0 { - return c - } - // A release outranks a prerelease of the same core version. - switch { - case a.pre == "" && b.pre == "": - return 0 - case a.pre == "": - return 1 - case b.pre == "": - return -1 - default: - return strings.Compare(a.pre, b.pre) - } -} - -func cmpInt(a, b int) int { - switch { - case a < b: - return -1 - case a > b: - return 1 - default: - return 0 + if s[0] != 'v' && s[0] != 'V' { + s = "v" + s + } else { + s = "v" + s[1:] } + return semver.Canonical(s) } diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index 1403074..bc246af 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -32,6 +32,11 @@ func (f *fakeSource) Download(_ context.Context, url string) ([]byte, error) { return b, nil } +// TestCompareVersions covers our normalization seam against +// golang.org/x/mod/semver: the leading v is optional, missing minor/patch +// expand to .0, and unparseable inputs (e.g. "dev") are treated as older than +// any real release. semver-internal ordering is upstream's job; we only smoke +// it here. func TestCompareVersions(t *testing.T) { t.Parallel() cases := []struct { @@ -39,16 +44,10 @@ func TestCompareVersions(t *testing.T) { want int }{ {"v1.0.0", "v1.0.1", -1}, - {"v1.2.0", "v1.1.9", 1}, - {"v1.2.3", "v1.2.3", 0}, {"1.2.3", "v1.2.3", 0}, // leading v optional - {"v2.0.0", "v1.9.9", 1}, - {"v1.0.0", "v1.0", 0}, // missing patch == .0 - {"v1.0.0-rc1", "v1.0.0", -1}, // prerelease < release - {"v1.0.0", "v1.0.0-rc1", 1}, // release > prerelease - {"v1.0.0+build", "v1.0.0", 0}, // build metadata ignored - {"dev", "v1.0.0", -1}, // unparseable current is older - {"v1.0.0", "dev", 1}, // unparseable latest is older + {"v1.0.0", "v1.0", 0}, // missing patch == .0 + {"dev", "v1.0.0", -1}, // unparseable current is older + {"v1.0.0", "dev", 1}, // unparseable latest is older } for _, tc := range cases { if got := compareVersions(tc.a, tc.b); got != tc.want { From 95a27e90b36a6eec54f319d6b9e2cbc8497e7b38 Mon Sep 17 00:00:00 2001 From: Jeppe Lillevang Salling Date: Sun, 21 Jun 2026 12:17:16 +0000 Subject: [PATCH 4/8] fix(selfupdate): tighten asset download cap from 200 MiB to 32 MiB Release archives are single-digit MiB; the previous 200 MiB ceiling let a misbehaving or hostile endpoint stream up to 199 MiB before the cap fired. 32 MiB is ~10x the largest plausible archive and a more honest ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/selfupdate/github.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/selfupdate/github.go b/internal/selfupdate/github.go index 897d264..d492565 100644 --- a/internal/selfupdate/github.go +++ b/internal/selfupdate/github.go @@ -12,9 +12,9 @@ import ( ) // maxDownloadBytes caps a single asset download. The release archives are a few -// MB; this is a generous ceiling that stops a misbehaving or hostile endpoint -// from streaming unbounded data into memory. -const maxDownloadBytes = 200 << 20 // 200 MiB +// MiB; 32 MiB is ~10x the largest plausible archive and stops a misbehaving or +// hostile endpoint from streaming unbounded data into memory. +const maxDownloadBytes = 32 << 20 // 32 MiB // GitHubSource implements Source against the GitHub releases REST API. type GitHubSource struct { From a2e77a0b0227907f773846ebaced78a936c96cd4 Mon Sep 17 00:00:00 2001 From: Jeppe Lillevang Salling Date: Sun, 21 Jun 2026 12:18:42 +0000 Subject: [PATCH 5/8] docs(cli): trim upgrade Behavior to user-facing facts The previous six bullets carried implementation rationale (atomic rename details, os.Executable symlink resolution) that already lives as code comments and is noise from a user's perspective. Cut to the facts a user needs: network call to GitHub, SHA-256 verification with fail-closed semantics, write permission required on the install path, dev-build needs --force. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cli.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 844d923..b7180cd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -154,11 +154,10 @@ agent-init upgrade --dry-run # download and verify, but do not replace the binar ### Behavior -- Queries `https://api.github.com/repos/Lillevang/agent-init/releases/latest` and compares the embedded `version` (the release tag, e.g. `v1.2.3`) against the latest tag using semver ordering. If `GITHUB_TOKEN` or `GH_TOKEN` is set, it is sent to lift the anonymous rate limit. -- Selects the asset matching the running OS/arch (`agent-init--.tar.gz`, or `.zip` on Windows), downloads it along with `checksums.txt`, and **verifies the archive's SHA-256 against the published checksum before installing**. A mismatch aborts the upgrade and leaves the existing binary untouched. -- Replaces the running binary atomically: the new binary is written to a temp file in the same directory, made executable, then renamed over the target (with a move-aside fallback for platforms that can't rename over a running executable). A failure mid-way never leaves a half-written binary in place. -- Resolves the install location with `os.Executable()` (following symlinks), so it replaces the real binary rather than a symlink to it. If the install directory is not writable (e.g. a root-owned `/usr/local/bin`), the command fails with a hint to re-run with elevated access rather than attempting privilege escalation itself. -- A dev build (`version=dev`) cannot be compared to a release; `upgrade` refuses unless `--force` is passed, which installs the latest release outright. +- Makes a network call to GitHub's releases API and downloads the OS/arch-specific asset plus `checksums.txt`. Honors `GITHUB_TOKEN` / `GH_TOKEN` to lift the anonymous rate limit. +- Verifies the archive's SHA-256 against the published checksum and swaps the binary in place. **Fails closed on checksum mismatch** — the existing binary is left untouched. +- Requires write permission on the binary's install directory. A root-owned install path (e.g. `/usr/local/bin`) cannot be upgraded without elevated access; `upgrade` reports the error rather than escalating itself. +- A dev build (`version=dev`) cannot be compared to a release; `upgrade` refuses unless `--force` is passed. - Source: [internal/selfupdate/selfupdate.go](../internal/selfupdate/selfupdate.go) (verify + replace), [internal/selfupdate/github.go](../internal/selfupdate/github.go) (releases client), [cli.go:runUpgrade](../internal/cli/cli.go). The release asset names this matches are cut by [.github/workflows/release.yml](../.github/workflows/release.yml). ## Help From 39848e1562ed3d06e6a333379a61677431311cb0 Mon Sep 17 00:00:00 2001 From: Jeppe Lillevang Salling Date: Sun, 21 Jun 2026 12:18:50 +0000 Subject: [PATCH 6/8] docs(agents): list upgrade in .agent/AGENTS.md subcommand reference .agent/AGENTS.md still listed five subcommands; this PR adds upgrade as the sixth. The canonical agent-facing CLI reference has to track the binary's actual surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agent/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agent/AGENTS.md b/.agent/AGENTS.md index 505e20c..6bd0c2d 100644 --- a/.agent/AGENTS.md +++ b/.agent/AGENTS.md @@ -66,6 +66,7 @@ The binary's interface is the product. Keep it small and stable. - `agent-init list-flavors` — print available flavors with descriptions. - `agent-init list-trackers` — print available trackers with descriptions. - `agent-init version` — print version info (commit + build date, embedded via `-ldflags`). +- `agent-init upgrade [--check] [--dry-run] [--force]` — update the binary in place from the latest GitHub release. Verifies SHA-256 against the published `checksums.txt` and fails closed on mismatch. See [`docs/cli.md`](../docs/cli.md#upgrade). ### Releases From 07448caee4a998dcfa49d9b52f146e529b790aeb Mon Sep 17 00:00:00 2001 From: Jeppe Lillevang Salling Date: Sun, 21 Jun 2026 12:18:57 +0000 Subject: [PATCH 7/8] test(selfupdate): exercise replaceBinary's move-aside fallback path The fallback that handles the Windows "can't rename over a running executable" case had no test driving it. Force the primary os.Rename(file, dir) to fail with EISDIR on POSIX by pre-creating target as a directory; the fallback then renames it aside and installs the new binary. Asserts the final binary contents match the new release, which the previous tests only confirmed on the happy path. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/selfupdate/selfupdate_test.go | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index bc246af..7497dce 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -339,6 +339,37 @@ func TestUpgradeMissingChecksums(t *testing.T) { } } +// TestUpgradeReplaceBinaryFallback exercises replaceBinary's move-aside +// fallback path: when os.Rename(tmp, target) fails (here forced by making +// target a directory, the cross-platform proxy for Windows' "can't rename over +// a running executable"), the fallback renames target out of the way and +// installs the new binary. The Windows production case is symmetric. +func TestUpgradeReplaceBinaryFallback(t *testing.T) { + t.Parallel() + dir := t.TempDir() + target := filepath.Join(dir, "agent-init") + // Make the target an (empty) directory so the primary os.Rename(file, dir) + // fails with EISDIR on POSIX, driving the fallback path. + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + newBin := []byte("BRAND NEW BINARY") + src := buildRelease(t, "v2.0.0", newBin) + var out bytes.Buffer + u := newTestUpdater(src, &out, target) + + if err := u.Upgrade(context.Background(), UpgradeOptions{Current: "v1.0.0"}); err != nil { + t.Fatalf("Upgrade(fallback) error = %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("reading installed binary: %v", err) + } + if !bytes.Equal(got, newBin) { + t.Errorf("binary after fallback = %q, want %q", got, newBin) + } +} + // TestExtractBinaryRejectsOversizedEntry asserts that an archive entry whose // uncompressed payload exceeds maxBinaryBytes errors out instead of being // allocated wholesale — the defense-in-depth check against a gzip/zip-bomb From 4e1def49392b2d0ba80306a9bfe2742c7624bef8 Mon Sep 17 00:00:00 2001 From: Jeppe Lillevang Salling Date: Sun, 21 Jun 2026 12:19:51 +0000 Subject: [PATCH 8/8] chore: regenerate codemap Picks up go.sum (added by the semver dep) and the file-count tick. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agent/CODEBASE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agent/CODEBASE.md b/.agent/CODEBASE.md index 1f68491..45ae329 100644 --- a/.agent/CODEBASE.md +++ b/.agent/CODEBASE.md @@ -15,7 +15,6 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ |-- Justfile |-- LICENSE |-- README.md -|-- agent-init |-- cmd | `-- agent-init | `-- main.go @@ -33,6 +32,7 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._ | |-- iac.md | `-- project-management.md |-- go.mod +|-- go.sum |-- internal | |-- cli | | |-- cli.go @@ -199,7 +199,7 @@ internal/gitignore/gitignore_test.go:234:func TestEnsureHiddenWritesNoGitignore( ## Stats ``` -Total tracked files: 350 +Total tracked files: 355 ```