From 2b499815dd190478334e8fac7334d994332c8d67 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 17:51:50 +0200 Subject: [PATCH 1/4] fix: replace abandoned rhysd/go-github-selfupdate grant update relied on github.com/rhysd/go-github-selfupdate (last commit Jan 2021), which pulled golang.org/x/crypto/openpgp into the build graph and with it GO-2026-5932 (unmaintained, no fix available), plus a tail of stale modules including golang.org/x/oauth2 from Nov 2018. Release discovery, asset selection, checksum verification and archive extraction are now implemented in internal/selfupdate/. Binary replacement is delegated to github.com/minio/selfupdate v0.6.0, which owns the atomic replace, fsync, Windows rename dance and rollback - code too dangerous to hand-roll on a path whose failure mode is a destroyed binary. This is a deliberate, documented exception to the zero-new-deps goal: net +1 module (aead.dev/minisign), -10 modules removed, 39 -> 33 in the build graph. grant update also now verifies the downloaded archive's SHA-256 against the release's checksums.txt; previously no validation happened at all. - go.mod: drop blang/semver, rhysd/go-github-selfupdate and their tail - cmd/interfaces.go: selfUpdater redefined over grant-owned types - govulncheck: GO-2026-5932 and GO-2025-3922 no longer reported --- CHANGELOG.md | 7 + CLAUDE.md | 11 +- README.md | 2 + cmd/interfaces.go | 7 +- cmd/test_mocks.go | 15 +- cmd/update.go | 34 +- cmd/update_test.go | 91 +++-- go.mod | 12 +- go.sum | 48 +-- internal/selfupdate/apply.go | 42 +++ internal/selfupdate/apply_test.go | 171 +++++++++ internal/selfupdate/selfupdate.go | 351 +++++++++++++++++ internal/selfupdate/selfupdate_test.go | 502 +++++++++++++++++++++++++ internal/selfupdate/version.go | 74 ++++ internal/selfupdate/version_test.go | 85 +++++ 15 files changed, 1336 insertions(+), 116 deletions(-) create mode 100644 internal/selfupdate/apply.go create mode 100644 internal/selfupdate/apply_test.go create mode 100644 internal/selfupdate/selfupdate.go create mode 100644 internal/selfupdate/selfupdate_test.go create mode 100644 internal/selfupdate/version.go create mode 100644 internal/selfupdate/version_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index af6023f..3322336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ All notable changes to this project will be documented in this file. ### Changed - Rebranded user-facing references from CyberArk to Idira (formerly CyberArk) in the README and CLI help/prompt text, following the Palo Alto Networks rebrand. No functional or API changes; SDK import paths (`github.com/cyberark/idsec-sdk-golang`), `*.cyberark.cloud` URLs, and environment variables are unchanged. +- `grant update` no longer depends on the abandoned `github.com/rhysd/go-github-selfupdate` (last commit Jan 2021). Release discovery, asset selection, checksum verification and archive extraction are now implemented in-house in `internal/selfupdate/`; the binary replacement (atomic rename, fsync, Windows rename dance, rollback) is delegated to `github.com/minio/selfupdate` v0.6.0 +- Dependency graph shrinks from 39 to 33 modules: removed `blang/semver`, `rhysd/go-github-selfupdate`, `google/go-github/v30`, `google/go-querystring`, `golang.org/x/oauth2` (a Nov 2018 pseudo-version), `golang/protobuf`, `google.golang.org/appengine`, `tcnksm/go-gitconfig`, `inconshreveable/go-update` and `ulikunitz/xz`; added `github.com/minio/selfupdate` and its single new transitive `aead.dev/minisign` + +### Security + +- `grant update` now verifies the downloaded archive's SHA-256 against the release's `checksums.txt` before replacing the binary. Previously no validation was performed at all (`selfupdate.DefaultUpdater()` ships a nil `Validator`). Note the trust model: `checksums.txt` is fetched from the same origin as the archive, so this protects against corrupted or tampered downloads in transit, not against a compromised GitHub account or release pipeline +- Removes `golang.org/x/crypto/openpgp` from the build graph, clearing advisory GO-2026-5932 (unmaintained package, no fix available). Dropping `ulikunitz/xz` also clears GO-2025-3922 ## [0.7.0] - 2026-04-21 diff --git a/CLAUDE.md b/CLAUDE.md index c0d02d8..df3a75a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Project - **Language:** Go 1.25+ - **Module:** `github.com/aaearon/grant-cli` -- **Sole dependency:** `github.com/cyberark/idsec-sdk-golang` — zero new Go module deps (all libs reused from SDK dep tree) +- **Dependencies:** `github.com/cyberark/idsec-sdk-golang` is the primary dependency; zero-new-Go-module-deps is a goal, not an absolute rule. Documented exception: `github.com/minio/selfupdate` (+ its one transitive `aead.dev/minisign`) for `grant update`, adopted to remove the abandoned `rhysd/go-github-selfupdate` and advisory GO-2026-5932. Net effect was 39 -> 33 modules ## SDK Import Conventions ```go @@ -82,7 +82,14 @@ Custom `SCAAccessService` follows SDK conventions: - `grant request cancel [id]` — cancel an open request; optional `--reason`. Omitting `` in a TTY opens a picker scoped to STARTING/RUNNING/PENDING requests you created (role=CREATOR) - `grant request approve [id]` / `grant request reject [id]` — finalize a request; optional `--reason`. Omitting `` in a TTY opens a picker scoped to PENDING requests assigned to you (role=APPROVER) - Request picker: `internal/ui/request_selector.go` mirrors the role-selector Format/Build/Select quartet; `resolveRequestIDFn` in `cmd/request_picker.go` is injectable for tests. Non-TTY invocation without `` returns `ErrNotInteractive` with a hint to run `grant request list` -- `grant update` — self-update binary via GitHub Releases (`rhysd/go-github-selfupdate`); guards against dev builds +- `grant update` — self-update binary via GitHub Releases; guards against dev builds. Implemented in `internal/selfupdate/`: + - Discovery: `GET https://api.github.com/repos/aaearon/grant-cli/releases/latest` (`apiBaseURL` field injectable for tests) + - Version compare: in-house `MAJOR.MINOR.PATCH` parser (`ParseVersion`/`CompareVersions`) — grant only emits GoReleaser-style tags, so no semver library + - Asset selection: `grant-cli___.tar.gz` (`.zip` on windows) — must stay in sync with `.goreleaser.yaml` + - Integrity: SHA-256 of the archive checked against the release's `checksums.txt`. **Trust model:** `checksums.txt` comes from the same origin as the archive, so it defends against corrupted/tampered downloads in transit, **not** against a compromised GitHub account or release pipeline. Signature verification would be needed for that + - Extraction: `archive/tar`+`compress/gzip` / `archive/zip`, rejecting absolute and `..` paths (gosec G305) and capped at 128 MiB via `io.LimitReader` (gosec G110) + - Apply: `github.com/minio/selfupdate` v0.6.0 owns the atomic replace, fsync, Windows rename dance and rollback — do not hand-roll this. Seam: `applyWithOptions` in `internal/selfupdate/apply.go` + - `selfUpdater` in `cmd/interfaces.go` is defined over grant-owned types: `UpdateSelf(ctx, current string) (newVersion string, updated bool, err error)` - `--groups` flag on root command shows only Entra ID groups in the interactive selector - `--group` / `-g` flag on root command for direct group membership elevation (`grant --group "Cloud Admins"`) - Root command unified selector shows both cloud roles and Entra ID groups; groups use `/eligibility/groups` and `/elevate/groups` API endpoints diff --git a/README.md b/README.md index 6b7f7d8..c41b6b5 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ sudo mv grant /usr/local/bin/ grant update ``` +`grant update` checks GitHub Releases for a newer version, verifies the downloaded archive's SHA-256 against the release's `checksums.txt`, and replaces the running binary in place (with rollback on failure). It refuses to run on dev builds — install a release build or download from the releases page. Because `checksums.txt` is published alongside the archive, verification protects against corrupted or tampered downloads, not against a compromised release pipeline. + **Windows:** Download `grant-cli__windows_.zip` from [releases](https://github.com/aaearon/grant-cli/releases) and extract to a directory in your PATH. ### Go Install diff --git a/cmd/interfaces.go b/cmd/interfaces.go index 4d8677e..c172e55 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -6,10 +6,8 @@ import ( "github.com/aaearon/grant-cli/internal/sca/models" "github.com/aaearon/grant-cli/internal/workflows" wfmodels "github.com/aaearon/grant-cli/internal/workflows/models" - "github.com/blang/semver" sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models" authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" - "github.com/rhysd/go-github-selfupdate/selfupdate" ) // authLoader interface for loading authentication @@ -87,9 +85,10 @@ type unifiedSelector interface { SelectItem(items []selectionItem) (*selectionItem, error) } -// selfUpdater interface for self-updating the binary via GitHub Releases +// selfUpdater interface for self-updating the binary via GitHub Releases. +// Returns the latest published version and whether the binary was replaced. type selfUpdater interface { - UpdateSelf(current semver.Version, slug string) (*selfupdate.Release, error) + UpdateSelf(ctx context.Context, current string) (newVersion string, updated bool, err error) } // accessRequestService interface for access request operations diff --git a/cmd/test_mocks.go b/cmd/test_mocks.go index 5e01aa3..d975d9c 100644 --- a/cmd/test_mocks.go +++ b/cmd/test_mocks.go @@ -8,10 +8,8 @@ import ( "github.com/aaearon/grant-cli/internal/sca/models" "github.com/aaearon/grant-cli/internal/workflows" wfmodels "github.com/aaearon/grant-cli/internal/workflows/models" - "github.com/blang/semver" sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models" authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" - "github.com/rhysd/go-github-selfupdate/selfupdate" ) // errNotAuthenticated is a sentinel error used in tests to simulate @@ -228,16 +226,19 @@ func (m *mockUnifiedSelector) SelectItem(items []selectionItem) (*selectionItem, // mockSelfUpdater implements selfUpdater interface for testing type mockSelfUpdater struct { - updateSelfFn func(semver.Version, string) (*selfupdate.Release, error) - release *selfupdate.Release + updateSelfFn func(ctx context.Context, current string) (string, bool, error) + newVersion string + updated bool updateErr error + gotCurrent string } -func (m *mockSelfUpdater) UpdateSelf(current semver.Version, slug string) (*selfupdate.Release, error) { +func (m *mockSelfUpdater) UpdateSelf(ctx context.Context, current string) (newVersion string, updated bool, err error) { + m.gotCurrent = current if m.updateSelfFn != nil { - return m.updateSelfFn(current, slug) + return m.updateSelfFn(ctx, current) } - return m.release, m.updateErr + return m.newVersion, m.updated, m.updateErr } // mockAccessRequestService implements accessRequestService for testing diff --git a/cmd/update.go b/cmd/update.go index 45d1cc2..1b191dc 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -1,20 +1,24 @@ package cmd import ( + "context" "errors" "fmt" "strings" + "time" - "github.com/blang/semver" - "github.com/rhysd/go-github-selfupdate/selfupdate" + "github.com/aaearon/grant-cli/internal/selfupdate" "github.com/spf13/cobra" ) const updateSlug = "aaearon/grant-cli" +// updateTimeout bounds the whole update: release lookup plus two downloads. +var updateTimeout = 5 * time.Minute + // NewUpdateCommand creates the update command with production dependencies func NewUpdateCommand() *cobra.Command { - return NewUpdateCommandWithDeps(selfupdate.DefaultUpdater()) + return NewUpdateCommandWithDeps(selfupdate.New(updateSlug)) } // NewUpdateCommandWithDeps creates the update command with injected dependencies @@ -22,7 +26,7 @@ func NewUpdateCommandWithDeps(updater selfUpdater) *cobra.Command { return &cobra.Command{ Use: "update", Short: "Update grant to the latest version", - Long: "Check GitHub Releases for a newer version of grant and replace the current binary in-place.", + Long: "Check GitHub Releases for a newer version of grant, verify its SHA-256 checksum against checksums.txt, and replace the current binary in-place.", RunE: func(cmd *cobra.Command, args []string) error { return runUpdate(cmd, updater) }, @@ -37,28 +41,32 @@ func runUpdate(cmd *cobra.Command, updater selfUpdater) error { log.Info("Current version: %s", v) - current, err := semver.Parse(strings.TrimPrefix(v, "v")) - if err != nil { - return fmt.Errorf("failed to parse current version %q: %w", v, err) - } + current := strings.TrimPrefix(v, "v") log.Info("Checking for updates from %s", updateSlug) - rel, err := updater.UpdateSelf(current, updateSlug) + parent := cmd.Context() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, updateTimeout) + defer cancel() + + latest, updated, err := updater.UpdateSelf(ctx, current) if err != nil { return fmt.Errorf("update failed: %w", err) } - if rel == nil { + if latest == "" { return errors.New("update check returned no release information") } - log.Info("Latest release: %s", rel.Version) + log.Info("Latest release: %s", latest) - if current.Equals(rel.Version) { + if !updated { fmt.Fprintf(cmd.OutOrStdout(), "grant %s is already up to date.\n", current) return nil } - fmt.Fprintf(cmd.OutOrStdout(), "Updated grant from %s to %s.\n", current, rel.Version) + fmt.Fprintf(cmd.OutOrStdout(), "Updated grant from %s to %s.\n", current, latest) return nil } diff --git a/cmd/update_test.go b/cmd/update_test.go index 8b79b93..837154f 100644 --- a/cmd/update_test.go +++ b/cmd/update_test.go @@ -1,12 +1,10 @@ package cmd import ( + "context" "errors" "strings" "testing" - - "github.com/blang/semver" - "github.com/rhysd/go-github-selfupdate/selfupdate" ) func TestUpdateCommand(t *testing.T) { @@ -38,11 +36,7 @@ func TestUpdateCommand(t *testing.T) { { name: "already up to date", version: "1.0.0", - updater: &mockSelfUpdater{ - release: &selfupdate.Release{ - Version: semver.MustParse("1.0.0"), - }, - }, + updater: &mockSelfUpdater{newVersion: "1.0.0", updated: false}, wantErr: false, wantContain: []string{ "already up to date", @@ -52,11 +46,7 @@ func TestUpdateCommand(t *testing.T) { { name: "successful update", version: "1.0.0", - updater: &mockSelfUpdater{ - release: &selfupdate.Release{ - Version: semver.MustParse("1.1.0"), - }, - }, + updater: &mockSelfUpdater{newVersion: "1.1.0", updated: true}, wantErr: false, wantContain: []string{ "1.0.0", @@ -66,9 +56,7 @@ func TestUpdateCommand(t *testing.T) { { name: "api error propagated", version: "1.0.0", - updater: &mockSelfUpdater{ - updateErr: errors.New("rate limit exceeded"), - }, + updater: &mockSelfUpdater{updateErr: errors.New("rate limit exceeded")}, wantErr: true, wantContain: []string{ "update failed", @@ -76,7 +64,7 @@ func TestUpdateCommand(t *testing.T) { }, }, { - name: "nil release without error", + name: "empty version without error", version: "1.0.0", updater: &mockSelfUpdater{}, wantErr: true, @@ -87,11 +75,7 @@ func TestUpdateCommand(t *testing.T) { { name: "version with v prefix", version: "v2.0.0", - updater: &mockSelfUpdater{ - release: &selfupdate.Release{ - Version: semver.MustParse("2.0.0"), - }, - }, + updater: &mockSelfUpdater{newVersion: "2.0.0", updated: false}, wantErr: false, wantContain: []string{ "already up to date", @@ -125,27 +109,59 @@ func TestUpdateCommand(t *testing.T) { } } -func TestUpdateCommandPassesSlug(t *testing.T) { +func TestUpdateCommandPassesNormalisedVersion(t *testing.T) { + oldVersion := version + version = "v1.0.0" + defer func() { version = oldVersion }() + + updater := &mockSelfUpdater{newVersion: "1.0.0"} + + cmd := NewUpdateCommandWithDeps(updater) + if _, err := executeCommand(cmd); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if updater.gotCurrent != "1.0.0" { + t.Errorf("updater received current version %q, want %q", updater.gotCurrent, "1.0.0") + } +} + +func TestUpdateCommandPassesContextWithDeadline(t *testing.T) { oldVersion := version version = "1.0.0" defer func() { version = oldVersion }() - var gotSlug string + var gotCtx context.Context updater := &mockSelfUpdater{ - updateSelfFn: func(v semver.Version, slug string) (*selfupdate.Release, error) { - gotSlug = slug - return &selfupdate.Release{Version: v}, nil + updateSelfFn: func(ctx context.Context, current string) (string, bool, error) { + gotCtx = ctx + return current, false, nil }, } cmd := NewUpdateCommandWithDeps(updater) - _, err := executeCommand(cmd) - if err != nil { + if _, err := executeCommand(cmd); err != nil { t.Fatalf("unexpected error: %v", err) } - if gotSlug != "aaearon/grant-cli" { - t.Errorf("expected slug %q, got %q", "aaearon/grant-cli", gotSlug) + if gotCtx == nil { + t.Fatal("updater received a nil context") + } + if _, ok := gotCtx.Deadline(); !ok { + t.Error("expected the update context to carry a deadline") + } +} + +func TestNewUpdateCommandUsesProductionUpdater(t *testing.T) { + cmd := NewUpdateCommand() + if cmd == nil { + t.Fatal("NewUpdateCommand returned nil") + } + if cmd.Use != "update" { + t.Errorf("Use = %q, want %q", cmd.Use, "update") + } + if updateSlug != "aaearon/grant-cli" { + t.Errorf("updateSlug = %q, want %q", updateSlug, "aaearon/grant-cli") } } @@ -159,11 +175,7 @@ func TestUpdateCommand_VerboseLogs(t *testing.T) { version = "1.0.0" defer func() { version = oldVersion }() - updater := &mockSelfUpdater{ - release: &selfupdate.Release{ - Version: semver.MustParse("1.1.0"), - }, - } + updater := &mockSelfUpdater{newVersion: "1.1.0", updated: true} cmd := NewUpdateCommandWithDeps(updater) _, err := executeCommand(cmd) @@ -175,6 +187,7 @@ func TestUpdateCommand_VerboseLogs(t *testing.T) { "Current version: 1.0.0", "Checking for updates", updateSlug, + "Latest release: 1.1.0", } for _, want := range wantMessages { @@ -193,11 +206,7 @@ func TestUpdateCommand_VerboseLogs(t *testing.T) { func TestUpdateCommandIntegration(t *testing.T) { rootCmd := newTestRootCommand() - updateCmd := NewUpdateCommandWithDeps(&mockSelfUpdater{ - release: &selfupdate.Release{ - Version: semver.MustParse("0.0.1"), - }, - }) + updateCmd := NewUpdateCommandWithDeps(&mockSelfUpdater{newVersion: "0.0.1"}) rootCmd.AddCommand(updateCmd) oldVersion := version diff --git a/go.mod b/go.mod index dc500d2..4c58b12 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,15 @@ go 1.25.0 require ( github.com/Iilun/survey/v2 v2.5.3 - github.com/blang/semver v3.5.1+incompatible github.com/cyberark/idsec-sdk-golang v0.2.3 github.com/mattn/go-isatty v0.0.20 - github.com/rhysd/go-github-selfupdate v1.2.3 + github.com/minio/selfupdate v0.6.0 github.com/spf13/cobra v1.9.1 gopkg.in/yaml.v3 v3.0.1 ) require ( + aead.dev/minisign v0.2.0 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/99designs/keyring v1.2.2 // indirect github.com/EDDYCJY/fake-useragent v0.2.0 // indirect @@ -22,13 +22,9 @@ require ( github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect - github.com/golang/protobuf v1.3.2 // indirect - github.com/google/go-github/v30 v30.1.0 // indirect - github.com/google/go-querystring v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/iancoleman/strcase v0.3.0 // indirect - github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a // indirect github.com/juju/persistent-cookiejar v1.0.0 // indirect @@ -37,20 +33,16 @@ require ( github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/tcnksm/go-gitconfig v0.1.2 // indirect github.com/toqueteos/webbrowser v1.2.0 // indirect - github.com/ulikunitz/xz v0.5.9 // indirect golang.design/x/clipboard v0.7.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/image v0.26.0 // indirect golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect - google.golang.org/appengine v1.3.0 // indirect gopkg.in/errgo.v1 v1.0.1 // indirect gopkg.in/retry.v1 v1.0.3 // indirect ) diff --git a/go.sum b/go.sum index 863ceee..69af499 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= @@ -13,8 +15,6 @@ github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiU github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -30,33 +30,22 @@ github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= -github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= -github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= -github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a h1:45JtCyuNYE+QN9aPuR1ID9++BQU+NMTMudHSuaK0Las= @@ -79,17 +68,14 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= -github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag= -github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg= github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a h1:3QH7VyOaaiUHNrA9Se4YQIRkDTCw1EJls9xTUCaCeRM= github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -103,12 +89,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= -github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE= github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ= github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= -github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= -github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.design/x/clipboard v0.6.3/go.mod h1:kqBSweBP0/im4SZGGjLrppH0D400Hnfo5WbFKSNK8N4= @@ -117,8 +99,9 @@ golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= @@ -144,13 +127,12 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -160,10 +142,6 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= -golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -172,13 +150,14 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -206,7 +185,6 @@ golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -229,21 +207,13 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= -google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso= gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs= gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/selfupdate/apply.go b/internal/selfupdate/apply.go new file mode 100644 index 0000000..4f2d4ec --- /dev/null +++ b/internal/selfupdate/apply.go @@ -0,0 +1,42 @@ +package selfupdate + +import ( + "bytes" + "crypto" + "crypto/sha256" + "errors" + "fmt" + + minio "github.com/minio/selfupdate" +) + +// applyBinary replaces the currently running executable with newBinary. +// The atomic replace, fsync, Windows rename dance and rollback are owned by +// github.com/minio/selfupdate. +func applyBinary(newBinary []byte) error { + return applyBinaryTo(newBinary, "") +} + +// applyBinaryTo replaces the binary at targetPath. An empty targetPath means +// the running executable. +func applyBinaryTo(newBinary []byte, targetPath string) error { + sum := sha256.Sum256(newBinary) + return applyWithOptions(newBinary, minio.Options{ + TargetPath: targetPath, + TargetMode: 0o755, + Hash: crypto.SHA256, + Checksum: sum[:], + }) +} + +// applyWithOptions is the single seam onto minio/selfupdate, so tests can +// exercise failure and rollback paths. +func applyWithOptions(newBinary []byte, opts minio.Options) error { + if err := minio.Apply(bytes.NewReader(newBinary), opts); err != nil { + if rollbackErr := minio.RollbackError(err); rollbackErr != nil { + return fmt.Errorf("update failed and rollback also failed, restore the binary manually: %w", errors.Join(err, rollbackErr)) + } + return err + } + return nil +} diff --git a/internal/selfupdate/apply_test.go b/internal/selfupdate/apply_test.go new file mode 100644 index 0000000..438ce74 --- /dev/null +++ b/internal/selfupdate/apply_test.go @@ -0,0 +1,171 @@ +package selfupdate + +import ( + "crypto" + "crypto/sha256" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + minio "github.com/minio/selfupdate" +) + +var errTestApply = errors.New("apply failed") + +const ( + oldBinaryContents = "OLD BINARY CONTENTS" + newBinaryContents = "NEW BINARY CONTENTS - LONGER THAN THE OLD ONE" +) + +// writeFakeBinary creates a stand-in for the running executable. +func writeFakeBinary(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "grant") + if err := os.WriteFile(path, []byte(oldBinaryContents), 0o755); err != nil { //nolint:gosec // test fixture + t.Fatalf("write fake binary: %v", err) + } + return path +} + +func TestApplyBinaryToReplacesTarget(t *testing.T) { + path := writeFakeBinary(t) + + if err := applyBinaryTo([]byte(newBinaryContents), path); err != nil { + t.Fatalf("applyBinaryTo: %v", err) + } + + got, err := os.ReadFile(path) //nolint:gosec // test fixture + if err != nil { + t.Fatalf("read back: %v", err) + } + if string(got) != newBinaryContents { + t.Errorf("target contents = %q, want %q", string(got), newBinaryContents) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o755 { + t.Errorf("mode = %o, want 0755", perm) + } + } +} + +func TestApplyBinaryFailureLeavesOriginalIntact(t *testing.T) { + tests := []struct { + name string + opts func(target string) minio.Options + }{ + { + name: "checksum mismatch", + opts: func(target string) minio.Options { + return minio.Options{ + TargetPath: target, + TargetMode: 0o755, + Hash: crypto.SHA256, + Checksum: make([]byte, sha256.Size), // all zeroes: cannot match + } + }, + }, + { + name: "checksum mismatch with rollback path", + opts: func(target string) minio.Options { + return minio.Options{ + TargetPath: target, + TargetMode: 0o755, + OldSavePath: target + ".old", + Hash: crypto.SHA256, + Checksum: make([]byte, sha256.Size), + } + }, + }, + { + name: "target directory does not exist", + opts: func(target string) minio.Options { + return minio.Options{ + TargetPath: filepath.Join(target, "no", "such", "dir", "grant"), + TargetMode: 0o755, + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeFakeBinary(t) + + err := applyWithOptions([]byte(newBinaryContents), tt.opts(path)) + if err == nil { + t.Fatal("expected error, got nil") + } + + got, readErr := os.ReadFile(path) //nolint:gosec // test fixture + if readErr != nil { + t.Fatalf("original binary is gone: %v", readErr) + } + // The binary must be either the untouched original or the fully + // written replacement - never a truncated hybrid. + if s := string(got); s != oldBinaryContents && s != newBinaryContents { + t.Errorf("binary is neither old nor new (possibly truncated): %q", s) + } + if string(got) != oldBinaryContents { + t.Errorf("expected original contents preserved, got %q", string(got)) + } + + // No stray .new file left behind next to the target. + if _, statErr := os.Stat(path + ".new"); statErr == nil { + t.Error("leftover .new file was not cleaned up") + } + }) + } +} + +func TestApplyBinaryRollbackRestoresOriginal(t *testing.T) { + path := writeFakeBinary(t) + oldSave := path + ".old" + + // A successful apply moves the original aside to OldSavePath. + err := applyWithOptions([]byte(newBinaryContents), minio.Options{ + TargetPath: path, + TargetMode: 0o755, + OldSavePath: oldSave, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + + saved, err := os.ReadFile(oldSave) //nolint:gosec // test fixture + if err != nil { + t.Fatalf("old binary was not saved for rollback: %v", err) + } + if string(saved) != oldBinaryContents { + t.Errorf("saved old binary = %q, want %q", string(saved), oldBinaryContents) + } + + // Simulate the user rolling back after a bad update. + if err := minio.RollbackError(errTestApply); err != nil && !errors.Is(err, errTestApply) { + t.Fatalf("unexpected rollback error: %v", err) + } +} + +func TestApplyBinaryVerifiesWhatItWrote(t *testing.T) { + path := writeFakeBinary(t) + + if err := applyBinaryTo([]byte(newBinaryContents), path); err != nil { + t.Fatalf("applyBinaryTo: %v", err) + } + + got, err := os.ReadFile(path) //nolint:gosec // test fixture + if err != nil { + t.Fatalf("read back: %v", err) + } + sum := sha256.Sum256(got) + want := sha256.Sum256([]byte(newBinaryContents)) + if sum != want { + t.Error("written binary does not match the source bytes") + } +} diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..1c9ba62 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,351 @@ +// Package selfupdate implements grant's self-update: GitHub release discovery, +// platform asset selection, SHA-256 checksum verification and archive +// extraction, with the binary replacement itself delegated to +// github.com/minio/selfupdate. +// +// Trust model: checksums.txt is published by GoReleaser alongside the archives +// and is fetched from the same origin as the binary. Verifying it protects +// against truncated or corrupted downloads and CDN/transport tampering. It does +// NOT protect against a compromised GitHub account or release pipeline, since +// an attacker able to replace the archive can also replace checksums.txt. +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bufio" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path" + "runtime" + "strings" + "time" +) + +const ( + // projectName matches GoReleaser's project name in the archive filenames. + projectName = "grant-cli" + // binaryName is the executable inside the release archive. + binaryName = "grant" + // checksumsFile is GoReleaser's checksum.name_template. + checksumsFile = "checksums.txt" + // defaultAPIBaseURL is the GitHub REST API root. + defaultAPIBaseURL = "https://api.github.com" + // maxDownloadBytes caps every download and every decompressed entry, + // guarding against decompression bombs (gosec G110). + maxDownloadBytes = 128 << 20 // 128 MiB + // downloadTimeout bounds the whole update operation. + downloadTimeout = 5 * time.Minute +) + +// httpDoer allows injecting a stub transport in tests. +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// ghAsset is a single release asset from the GitHub API. +type ghAsset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} + +// ghRelease is the subset of the GitHub releases/latest payload grant needs. +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []ghAsset `json:"assets"` +} + +// assetURL returns the download URL for the named asset. +func (r *ghRelease) assetURL(name string) (string, error) { + for _, a := range r.Assets { + if a.Name == name { + return a.URL, nil + } + } + return "", fmt.Errorf("release %s has no asset named %q", r.TagName, name) +} + +// Updater checks GitHub Releases and replaces the running binary. +type Updater struct { + slug string + apiBaseURL string + client httpDoer + goos string + goarch string + // applyFn replaces the running executable; injectable for tests. + applyFn func(newBinary []byte) error +} + +// New creates an Updater for the given "owner/repo" slug. +func New(slug string) *Updater { + return &Updater{ + slug: slug, + apiBaseURL: defaultAPIBaseURL, + client: &http.Client{Timeout: downloadTimeout}, + goos: runtime.GOOS, + goarch: runtime.GOARCH, + applyFn: applyBinary, + } +} + +// UpdateSelf checks for a newer release and, if one exists, downloads, +// verifies and installs it. It returns the latest published version, whether +// the binary was replaced, and any error. +func (u *Updater) UpdateSelf(ctx context.Context, current string) (newVersion string, updated bool, err error) { + currentVer, err := ParseVersion(current) + if err != nil { + return "", false, err + } + + rel, err := u.fetchLatestRelease(ctx) + if err != nil { + return "", false, err + } + + latestVer, err := ParseVersion(rel.TagName) + if err != nil { + return "", false, fmt.Errorf("latest release tag %q: %w", rel.TagName, err) + } + + if currentVer.Compare(latestVer) >= 0 { + return latestVer.String(), false, nil + } + + assetName := assetNameFor(u.goos, u.goarch, latestVer.String()) + assetURL, err := rel.assetURL(assetName) + if err != nil { + return "", false, err + } + checksumsURL, err := rel.assetURL(checksumsFile) + if err != nil { + return "", false, err + } + + archive, err := u.download(ctx, assetURL) + if err != nil { + return "", false, fmt.Errorf("failed to download %s: %w", assetName, err) + } + + checksums, err := u.download(ctx, checksumsURL) + if err != nil { + return "", false, fmt.Errorf("failed to download %s: %w", checksumsFile, err) + } + + if err := verifyChecksum(checksums, assetName, archive); err != nil { + return "", false, err + } + + bin, err := extractBinary(archive, assetName) + if err != nil { + return "", false, err + } + + if err := u.applyFn(bin); err != nil { + return "", false, fmt.Errorf("failed to replace binary: %w", err) + } + + return latestVer.String(), true, nil +} + +// fetchLatestRelease queries GitHub for the repository's latest release. +func (u *Updater) fetchLatestRelease(ctx context.Context) (*ghRelease, error) { + url := fmt.Sprintf("%s/repos/%s/releases/latest", strings.TrimSuffix(u.apiBaseURL, "/"), u.slug) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to build release request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := u.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to query GitHub releases: %w", err) + } + defer resp.Body.Close() //nolint:errcheck // nothing actionable on close + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadBytes)) + if err != nil { + return nil, fmt.Errorf("failed to read GitHub response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub returned %d for %s: %s", resp.StatusCode, url, strings.TrimSpace(string(body))) + } + + var rel ghRelease + if err := json.Unmarshal(body, &rel); err != nil { + return nil, fmt.Errorf("failed to decode GitHub release response: %w", err) + } + if rel.TagName == "" { + return nil, errors.New("GitHub release response has no tag_name") + } + return &rel, nil +} + +// download fetches a URL into memory, capped at maxDownloadBytes. +func (u *Updater) download(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to build download request: %w", err) + } + + resp, err := u.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck // nothing actionable on close + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download returned status %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadBytes)) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, errors.New("download was empty") + } + return data, nil +} + +// assetNameFor builds the GoReleaser archive name for a platform. +// Mirrors .goreleaser.yaml: default archive name_template with a zip override +// on Windows. +func assetNameFor(goos, goarch, version string) string { + ext := ".tar.gz" + if goos == "windows" { + ext = ".zip" + } + return fmt.Sprintf("%s_%s_%s_%s%s", projectName, strings.TrimPrefix(version, "v"), goos, goarch, ext) +} + +// verifyChecksum checks data against the entry for filename in a GoReleaser +// checksums.txt (" " per line). +func verifyChecksum(checksums []byte, filename string, data []byte) error { + want := "" + scanner := bufio.NewScanner(bytes.NewReader(checksums)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 2 { + return fmt.Errorf("malformed line in %s: %q", checksumsFile, line) + } + if fields[1] == filename { + want = fields[0] + break + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to read %s: %w", checksumsFile, err) + } + if want == "" { + return fmt.Errorf("%s has no entry for %s", checksumsFile, filename) + } + + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if !strings.EqualFold(got, want) { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", filename, got, want) + } + return nil +} + +// extractBinary pulls the grant executable out of a release archive. +func extractBinary(archive []byte, assetName string) ([]byte, error) { + switch { + case strings.HasSuffix(assetName, ".tar.gz"): + return extractFromTarGz(archive) + case strings.HasSuffix(assetName, ".zip"): + return extractFromZip(archive) + default: + return nil, fmt.Errorf("unsupported archive format: %s", assetName) + } +} + +// isBinaryEntry reports whether an archive entry is the grant executable. +func isBinaryEntry(name string) bool { + base := path.Base(name) + return base == binaryName || base == binaryName+".exe" +} + +// checkArchivePath rejects absolute paths and traversal (gosec G305). +func checkArchivePath(name string) error { + cleaned := path.Clean(strings.ReplaceAll(name, `\`, "/")) + if path.IsAbs(cleaned) || strings.HasPrefix(cleaned, "../") || cleaned == ".." { + return fmt.Errorf("archive contains illegal path %q", name) + } + return nil +} + +func extractFromTarGz(archive []byte) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("failed to open gzip stream: %w", err) + } + defer gz.Close() //nolint:errcheck // read-only stream + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("failed to read tar archive: %w", err) + } + if err := checkArchivePath(hdr.Name); err != nil { + return nil, err + } + if hdr.Typeflag != tar.TypeReg || !isBinaryEntry(hdr.Name) { + continue + } + data, err := io.ReadAll(io.LimitReader(tr, maxDownloadBytes)) // gosec G110 + if err != nil { + return nil, fmt.Errorf("failed to read %s from archive: %w", hdr.Name, err) + } + return data, nil + } + return nil, fmt.Errorf("archive does not contain a %s binary", binaryName) +} + +func extractFromZip(archive []byte) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("failed to open zip archive: %w", err) + } + + for _, f := range zr.File { + if err := checkArchivePath(f.Name); err != nil { + return nil, err + } + if f.FileInfo().IsDir() || !isBinaryEntry(f.Name) { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("failed to open %s in archive: %w", f.Name, err) + } + data, err := io.ReadAll(io.LimitReader(rc, maxDownloadBytes)) // gosec G110 + closeErr := rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read %s from archive: %w", f.Name, err) + } + if closeErr != nil { + return nil, fmt.Errorf("failed to close %s in archive: %w", f.Name, closeErr) + } + return data, nil + } + return nil, fmt.Errorf("archive does not contain a %s binary", binaryName) +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..8955fec --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,502 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAssetNameFor(t *testing.T) { + tests := []struct { + name string + goos string + goarch string + version string + want string + }{ + {name: "linux amd64", goos: "linux", goarch: "amd64", version: "0.7.0", want: "grant-cli_0.7.0_linux_amd64.tar.gz"}, + {name: "linux arm64", goos: "linux", goarch: "arm64", version: "0.7.0", want: "grant-cli_0.7.0_linux_arm64.tar.gz"}, + {name: "darwin amd64", goos: "darwin", goarch: "amd64", version: "0.7.0", want: "grant-cli_0.7.0_darwin_amd64.tar.gz"}, + {name: "darwin arm64", goos: "darwin", goarch: "arm64", version: "1.10.2", want: "grant-cli_1.10.2_darwin_arm64.tar.gz"}, + {name: "windows amd64 is zip", goos: "windows", goarch: "amd64", version: "0.7.0", want: "grant-cli_0.7.0_windows_amd64.zip"}, + {name: "windows arm64 is zip", goos: "windows", goarch: "arm64", version: "0.7.0", want: "grant-cli_0.7.0_windows_arm64.zip"}, + {name: "tag with v prefix is normalised", goos: "linux", goarch: "amd64", version: "v0.7.0", want: "grant-cli_0.7.0_linux_amd64.tar.gz"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := assetNameFor(tt.goos, tt.goarch, tt.version); got != tt.want { + t.Errorf("assetNameFor(%q, %q, %q) = %q, want %q", tt.goos, tt.goarch, tt.version, got, tt.want) + } + }) + } +} + +const latestReleaseFixture = `{ + "tag_name": "v0.7.0", + "assets": [ + {"name": "checksums.txt", "browser_download_url": "%[1]s/download/checksums.txt"}, + {"name": "grant-cli_0.7.0_linux_amd64.tar.gz", "browser_download_url": "%[1]s/download/grant-cli_0.7.0_linux_amd64.tar.gz"}, + {"name": "grant-cli_0.7.0_windows_amd64.zip", "browser_download_url": "%[1]s/download/grant-cli_0.7.0_windows_amd64.zip"} + ] +}` + +func TestFetchLatestRelease(t *testing.T) { + tests := []struct { + name string + status int + body string + wantErr bool + wantErrHas string + wantTag string + }{ + { + name: "happy path", + status: http.StatusOK, + body: latestReleaseFixture, + wantTag: "v0.7.0", + }, + { + name: "not found", + status: http.StatusNotFound, + body: `{"message":"Not Found"}`, + wantErr: true, + wantErrHas: "404", + }, + { + name: "rate limited", + status: http.StatusForbidden, + body: `{"message":"API rate limit exceeded for 1.2.3.4."}`, + wantErr: true, + wantErrHas: "rate limit", + }, + { + name: "malformed json", + status: http.StatusOK, + body: `{"tag_name": `, + wantErr: true, + wantErrHas: "decode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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.WriteHeader(tt.status) + fmt.Fprintf(w, tt.body, "http://example.invalid") + })) + defer srv.Close() + + u := New("aaearon/grant-cli") + u.apiBaseURL = srv.URL + + rel, err := u.fetchLatestRelease(t.Context()) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got release %+v", rel) + } + if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.wantErrHas)) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantErrHas) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rel.TagName != tt.wantTag { + t.Errorf("tag = %q, want %q", rel.TagName, tt.wantTag) + } + if gotPath != "/repos/aaearon/grant-cli/releases/latest" { + t.Errorf("requested path = %q", gotPath) + } + if len(rel.Assets) != 3 { + t.Fatalf("expected 3 assets, got %d", len(rel.Assets)) + } + }) + } +} + +func TestReleaseAssetURL(t *testing.T) { + rel := &ghRelease{ + TagName: "v0.7.0", + Assets: []ghAsset{ + {Name: "checksums.txt", URL: "https://example.invalid/checksums.txt"}, + {Name: "grant-cli_0.7.0_linux_amd64.tar.gz", URL: "https://example.invalid/linux.tar.gz"}, + }, + } + + got, err := rel.assetURL("grant-cli_0.7.0_linux_amd64.tar.gz") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "https://example.invalid/linux.tar.gz" { + t.Errorf("got %q", got) + } + + if _, err := rel.assetURL("grant-cli_0.7.0_plan9_amd64.tar.gz"); err == nil { + t.Error("expected error for missing asset, got nil") + } +} + +func TestVerifyChecksum(t *testing.T) { + payload := []byte("fake archive bytes") + sum := sha256.Sum256(payload) + good := hex.EncodeToString(sum[:]) + + tests := []struct { + name string + checksums string + filename string + data []byte + wantErr bool + }{ + { + name: "match", + checksums: good + " grant-cli_0.7.0_linux_amd64.tar.gz\n", + filename: "grant-cli_0.7.0_linux_amd64.tar.gz", + data: payload, + }, + { + name: "match among several lines", + checksums: "0000000000000000000000000000000000000000000000000000000000000000 checksums-other.txt\n" + + good + " grant-cli_0.7.0_linux_amd64.tar.gz\n", + filename: "grant-cli_0.7.0_linux_amd64.tar.gz", + data: payload, + }, + { + name: "mismatch", + checksums: "0000000000000000000000000000000000000000000000000000000000000000 grant-cli_0.7.0_linux_amd64.tar.gz\n", + filename: "grant-cli_0.7.0_linux_amd64.tar.gz", + data: payload, + wantErr: true, + }, + { + name: "filename absent", + checksums: good + " some-other-file.tar.gz\n", + filename: "grant-cli_0.7.0_linux_amd64.tar.gz", + data: payload, + wantErr: true, + }, + { + name: "malformed line", + checksums: "deadbeef\n", + filename: "grant-cli_0.7.0_linux_amd64.tar.gz", + data: payload, + wantErr: true, + }, + { + name: "empty checksums", + checksums: "", + filename: "grant-cli_0.7.0_linux_amd64.tar.gz", + data: payload, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := verifyChecksum([]byte(tt.checksums), tt.filename, tt.data) + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// buildTarGz builds an in-memory tar.gz archive from name -> contents. +func buildTarGz(t *testing.T, entries [][2]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, e := range entries { + hdr := &tar.Header{Name: e[0], Mode: 0o755, Size: int64(len(e[1])), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write([]byte(e[1])); err != nil { + t.Fatalf("write tar body: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + return buf.Bytes() +} + +// buildZip builds an in-memory zip archive from name -> contents. +func buildZip(t *testing.T, entries [][2]string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, e := range entries { + w, err := zw.Create(e[0]) + if err != nil { + t.Fatalf("create zip entry: %v", err) + } + if _, err := w.Write([]byte(e[1])); err != nil { + t.Fatalf("write zip entry: %v", err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + return buf.Bytes() +} + +func TestExtractBinary(t *testing.T) { + const binContents = "\x7fELF fake grant binary" + + tests := []struct { + name string + assetName string + archive []byte + wantErr bool + want string + }{ + { + name: "tar.gz with decoy files", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: buildTarGz(t, [][2]string{ + {"README.md", "not the binary"}, + {"LICENSE", "MIT"}, + {"grant", binContents}, + }), + want: binContents, + }, + { + name: "zip with decoy files", + assetName: "grant-cli_0.7.0_windows_amd64.zip", + archive: buildZip(t, [][2]string{ + {"README.md", "not the binary"}, + {"grant.exe", binContents}, + }), + want: binContents, + }, + { + name: "tar.gz path traversal rejected", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: buildTarGz(t, [][2]string{ + {"../evil", "pwned"}, + {"grant", binContents}, + }), + wantErr: true, + }, + { + name: "zip path traversal rejected", + assetName: "grant-cli_0.7.0_windows_amd64.zip", + archive: buildZip(t, [][2]string{ + {"../evil", "pwned"}, + {"grant.exe", binContents}, + }), + wantErr: true, + }, + { + name: "tar.gz absolute path rejected", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: buildTarGz(t, [][2]string{ + {"/etc/passwd", "pwned"}, + }), + wantErr: true, + }, + { + name: "tar.gz without binary", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: buildTarGz(t, [][2]string{{"README.md", "nope"}}), + wantErr: true, + }, + { + name: "zip without binary", + assetName: "grant-cli_0.7.0_windows_amd64.zip", + archive: buildZip(t, [][2]string{{"README.md", "nope"}}), + wantErr: true, + }, + { + name: "corrupt gzip", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: []byte("not a gzip stream"), + wantErr: true, + }, + { + name: "corrupt zip", + assetName: "grant-cli_0.7.0_windows_amd64.zip", + archive: []byte("not a zip file"), + wantErr: true, + }, + { + name: "unknown archive format", + assetName: "grant-cli_0.7.0_linux_amd64.rar", + archive: []byte("whatever"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractBinary(tt.archive, tt.assetName) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %d bytes", len(got)) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != tt.want { + t.Errorf("extracted %q, want %q", string(got), tt.want) + } + }) + } +} + +// newFixtureServer serves a GitHub releases/latest response plus the referenced assets. +func newFixtureServer(t *testing.T, archiveName string, archive, checksums []byte) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + mux.HandleFunc("/repos/aaearon/grant-cli/releases/latest", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"tag_name":"v0.7.0","assets":[ + {"name":"checksums.txt","browser_download_url":"%[1]s/download/checksums.txt"}, + {"name":%[2]q,"browser_download_url":"%[1]s/download/%[2]s"} + ]}`, srv.URL, archiveName) + }) + mux.HandleFunc("/download/checksums.txt", func(w http.ResponseWriter, r *http.Request) { + w.Write(checksums) //nolint:errcheck // test server + }) + mux.HandleFunc("/download/"+archiveName, func(w http.ResponseWriter, r *http.Request) { + w.Write(archive) //nolint:errcheck // test server + }) + return srv +} + +func checksumsFor(name string, data []byte) []byte { + sum := sha256.Sum256(data) + return fmt.Appendf(nil, "%s %s\n", hex.EncodeToString(sum[:]), name) +} + +func TestUpdateSelf(t *testing.T) { + const newBinary = "brand new grant binary" + archiveName := "grant-cli_0.7.0_linux_amd64.tar.gz" + archive := buildTarGz(t, [][2]string{{"grant", newBinary}}) + + t.Run("already up to date", func(t *testing.T) { + srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) + u := New("aaearon/grant-cli") + u.apiBaseURL = srv.URL + u.goos, u.goarch = "linux", "amd64" + + applied := false + u.applyFn = func(b []byte) error { applied = true; return nil } + + newVer, updated, err := u.UpdateSelf(t.Context(), "0.7.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated { + t.Error("expected updated=false") + } + if applied { + t.Error("apply must not run when already up to date") + } + if newVer != "0.7.0" { + t.Errorf("newVersion = %q, want 0.7.0", newVer) + } + }) + + t.Run("newer version applied", func(t *testing.T) { + srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) + u := New("aaearon/grant-cli") + u.apiBaseURL = srv.URL + u.goos, u.goarch = "linux", "amd64" + + var got []byte + u.applyFn = func(b []byte) error { got = b; return nil } + + newVer, updated, err := u.UpdateSelf(t.Context(), "0.6.1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !updated { + t.Error("expected updated=true") + } + if newVer != "0.7.0" { + t.Errorf("newVersion = %q, want 0.7.0", newVer) + } + if string(got) != newBinary { + t.Errorf("applied %q, want %q", string(got), newBinary) + } + }) + + t.Run("checksum mismatch aborts before apply", func(t *testing.T) { + bad := checksumsFor(archiveName, []byte("different bytes entirely")) + srv := newFixtureServer(t, archiveName, archive, bad) + u := New("aaearon/grant-cli") + u.apiBaseURL = srv.URL + u.goos, u.goarch = "linux", "amd64" + + applied := false + u.applyFn = func(b []byte) error { applied = true; return nil } + + if _, _, err := u.UpdateSelf(t.Context(), "0.6.1"); err == nil { + t.Fatal("expected checksum error, got nil") + } + if applied { + t.Error("apply must not run when the checksum does not match") + } + }) + + t.Run("no asset for platform", func(t *testing.T) { + srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) + u := New("aaearon/grant-cli") + u.apiBaseURL = srv.URL + u.goos, u.goarch = "plan9", "386" + u.applyFn = func(b []byte) error { return nil } + + if _, _, err := u.UpdateSelf(t.Context(), "0.6.1"); err == nil { + t.Fatal("expected error for unavailable asset, got nil") + } + }) + + t.Run("invalid current version", func(t *testing.T) { + srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) + u := New("aaearon/grant-cli") + u.apiBaseURL = srv.URL + u.applyFn = func(b []byte) error { return nil } + + if _, _, err := u.UpdateSelf(t.Context(), "not-a-version"); err == nil { + t.Fatal("expected error for invalid current version, got nil") + } + }) + + t.Run("apply error propagated", func(t *testing.T) { + srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) + u := New("aaearon/grant-cli") + u.apiBaseURL = srv.URL + u.goos, u.goarch = "linux", "amd64" + u.applyFn = func(b []byte) error { return errTestApply } + + if _, _, err := u.UpdateSelf(t.Context(), "0.6.1"); err == nil { + t.Fatal("expected apply error, got nil") + } + }) +} diff --git a/internal/selfupdate/version.go b/internal/selfupdate/version.go new file mode 100644 index 0000000..dd7b056 --- /dev/null +++ b/internal/selfupdate/version.go @@ -0,0 +1,74 @@ +package selfupdate + +import ( + "fmt" + "strconv" + "strings" +) + +// Version is a GoReleaser-style MAJOR.MINOR.PATCH version. +// grant only ever emits tags in this shape, so pre-release and build +// metadata are deliberately unsupported. +type Version struct { + Major int + Minor int + Patch int +} + +// String renders the version without a leading "v". +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +// Compare returns -1 if v < other, 0 if equal, 1 if v > other. +func (v Version) Compare(other Version) int { + for _, pair := range [3][2]int{ + {v.Major, other.Major}, + {v.Minor, other.Minor}, + {v.Patch, other.Patch}, + } { + switch { + case pair[0] < pair[1]: + return -1 + case pair[0] > pair[1]: + return 1 + } + } + return 0 +} + +// ParseVersion parses a MAJOR.MINOR.PATCH version, tolerating a leading "v". +func ParseVersion(s string) (Version, error) { + trimmed := strings.TrimPrefix(strings.TrimSpace(s), "v") + parts := strings.Split(trimmed, ".") + if len(parts) != 3 { + return Version{}, fmt.Errorf("invalid version %q: expected MAJOR.MINOR.PATCH", s) + } + + var v Version + targets := [3]*int{&v.Major, &v.Minor, &v.Patch} + for i, part := range parts { + n, err := strconv.Atoi(part) + if err != nil { + return Version{}, fmt.Errorf("invalid version %q: %w", s, err) + } + if n < 0 { + return Version{}, fmt.Errorf("invalid version %q: negative component", s) + } + *targets[i] = n + } + return v, nil +} + +// CompareVersions parses both versions and compares them. +func CompareVersions(a, b string) (int, error) { + va, err := ParseVersion(a) + if err != nil { + return 0, err + } + vb, err := ParseVersion(b) + if err != nil { + return 0, err + } + return va.Compare(vb), nil +} diff --git a/internal/selfupdate/version_test.go b/internal/selfupdate/version_test.go new file mode 100644 index 0000000..c7d1bce --- /dev/null +++ b/internal/selfupdate/version_test.go @@ -0,0 +1,85 @@ +package selfupdate + +import "testing" + +func TestParseVersion(t *testing.T) { + tests := []struct { + name string + input string + want Version + wantErr bool + }{ + {name: "plain", input: "1.2.3", want: Version{Major: 1, Minor: 2, Patch: 3}}, + {name: "v prefix", input: "v1.2.3", want: Version{Major: 1, Minor: 2, Patch: 3}}, + {name: "zeroes", input: "0.0.0", want: Version{}}, + {name: "double digit", input: "1.10.0", want: Version{Major: 1, Minor: 10}}, + {name: "empty", input: "", wantErr: true}, + {name: "too few parts", input: "1.2", wantErr: true}, + {name: "too many parts", input: "1.2.3.4", wantErr: true}, + {name: "non numeric", input: "1.x.3", wantErr: true}, + {name: "negative", input: "1.-2.3", wantErr: true}, + {name: "prerelease unsupported", input: "1.2.3-rc1", wantErr: true}, + {name: "just v", input: "v", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseVersion(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got %+v", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("ParseVersion(%q) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} + +func TestVersionString(t *testing.T) { + v := Version{Major: 1, Minor: 10, Patch: 2} + if got := v.String(); got != "1.10.2" { + t.Errorf("String() = %q, want %q", got, "1.10.2") + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + a, b string + want int + wantErr bool + }{ + {name: "patch less", a: "1.0.0", b: "1.0.1", want: -1}, + {name: "v prefix equal", a: "v1.2.3", b: "1.2.3", want: 0}, + {name: "minor numeric not lexical", a: "1.10.0", b: "1.9.0", want: 1}, + {name: "equal", a: "1.0.0", b: "1.0.0", want: 0}, + {name: "major greater", a: "2.0.0", b: "1.99.99", want: 1}, + {name: "major less", a: "0.9.0", b: "1.0.0", want: -1}, + {name: "invalid a", a: "nope", b: "1.0.0", wantErr: true}, + {name: "invalid b", a: "1.0.0", b: "nope", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CompareVersions(tt.a, tt.b) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %d", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("CompareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} From c561735bf434e1addf59ba120260fc3e25441ef3 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 18:27:35 +0200 Subject: [PATCH 2/4] fix: harden selfupdate against truncation, fix version parsing, correct docs Addresses the review findings on PR #51. CRITICAL - oversized decompressed binaries were silently truncated and installed. io.LimitReader reports a *successful* short read when the source is longer than the cap, and the checksum covers the archive, not the extracted bytes, so a checksum-valid archive holding a >128 MiB binary handed its first 128 MiB to Apply. readCapped now probes one byte past the cap, and both archive readers reject entries whose declared size exceeds the cap or disagrees with the bytes actually read. Same fix on both HTTP download paths. CRITICAL - the atomicity and fsync claims were false. minio never syncs the staged file, and the two-rename swap has a window that leaves the target absent. grant now fsyncs the staged file before the swap (plus a best-effort directory sync after it), and the documentation states what actually holds: each rename is atomic, the pair is not. InterruptedUpdate detects the half-swapped state and recoveryHint prints the mv command that repairs it. HIGH - the version parser rejected 1.2.3-rc.1 and 1.2.3+build, so a prerelease build could not update at all. Replaced with a SemVer 2.0.0 parser and precedence comparison: build metadata ignored for ordering, prerelease sorts before its release, leading zeroes rejected, v/V both accepted deliberately. HIGH - the rollback tests did not test rollback. They now drive the real path via the commitFn seam: second-rename failure with successful rollback, the unrecoverable rollback-failure end state and its documented recovery, interruption detection, partial staged writes, unwritable target directory, and symlink targets. Also: tolerate the GNU "*filename" checksum marker; reject drive-absolute and UNC archive paths; accept only a single root-level grant/grant.exe entry; document that the installed mode is always 0755; restate the dependency numbers precisely (build graph 39->33, go.mod requires 47->39, go list -m all 110->95). --- CHANGELOG.md | 6 +- CLAUDE.md | 11 +- README.md | 4 +- internal/selfupdate/apply.go | 141 ++++++++++- internal/selfupdate/apply_test.go | 337 ++++++++++++++++++++----- internal/selfupdate/selfupdate.go | 140 ++++++++-- internal/selfupdate/selfupdate_test.go | 240 ++++++++++++++++++ internal/selfupdate/version.go | 193 +++++++++++++- internal/selfupdate/version_test.go | 89 ++++++- 9 files changed, 1039 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3322336..2bdcd34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,14 @@ All notable changes to this project will be documented in this file. ### Changed - Rebranded user-facing references from CyberArk to Idira (formerly CyberArk) in the README and CLI help/prompt text, following the Palo Alto Networks rebrand. No functional or API changes; SDK import paths (`github.com/cyberark/idsec-sdk-golang`), `*.cyberark.cloud` URLs, and environment variables are unchanged. -- `grant update` no longer depends on the abandoned `github.com/rhysd/go-github-selfupdate` (last commit Jan 2021). Release discovery, asset selection, checksum verification and archive extraction are now implemented in-house in `internal/selfupdate/`; the binary replacement (atomic rename, fsync, Windows rename dance, rollback) is delegated to `github.com/minio/selfupdate` v0.6.0 -- Dependency graph shrinks from 39 to 33 modules: removed `blang/semver`, `rhysd/go-github-selfupdate`, `google/go-github/v30`, `google/go-querystring`, `golang.org/x/oauth2` (a Nov 2018 pseudo-version), `golang/protobuf`, `google.golang.org/appengine`, `tcnksm/go-gitconfig`, `inconshreveable/go-update` and `ulikunitz/xz`; added `github.com/minio/selfupdate` and its single new transitive `aead.dev/minisign` +- `grant update` no longer depends on the abandoned `github.com/rhysd/go-github-selfupdate` (last commit Jan 2021). Release discovery, version comparison, asset selection, checksum verification and archive extraction are now implemented in-house in `internal/selfupdate/`; the binary swap (staged sibling file, the two-rename dance including the Windows path, and rollback) is delegated to `github.com/minio/selfupdate` v0.6.0, with grant adding an `fsync` of the staged file before the swap +- **Replacement guarantees, stated precisely.** Each rename is individually atomic, so the installed binary is never a partially written file. The *pair* of renames is not atomic: if the process is killed between them, or if the second rename and the rollback both fail, the binary path is left absent with `.grant.old` and `.grant.new` beside it. grant now detects that state and prints the exact `mv` command that restores the previous binary +- Dependency graph shrinks. Build graph (`go list -deps`, the modules actually compiled in): 39 -> 33. `go.mod` require directives: 47 -> 39 (indirect 40 -> 33). Full module graph (`go list -m all`): 110 -> 95. The only genuinely new module paths are `github.com/minio/selfupdate` and `aead.dev/minisign`. Removed `blang/semver`, `rhysd/go-github-selfupdate`, `google/go-github/v30`, `google/go-querystring`, `golang.org/x/oauth2` (a Nov 2018 pseudo-version), `golang/protobuf`, `google.golang.org/appengine`, `tcnksm/go-gitconfig`, `inconshreveable/go-update` and `ulikunitz/xz` ### Security - `grant update` now verifies the downloaded archive's SHA-256 against the release's `checksums.txt` before replacing the binary. Previously no validation was performed at all (`selfupdate.DefaultUpdater()` ships a nil `Validator`). Note the trust model: `checksums.txt` is fetched from the same origin as the archive, so this protects against corrupted or tampered downloads in transit, not against a compromised GitHub account or release pipeline +- Archive extraction now rejects oversized entries instead of silently truncating them at the 128 MiB cap, rejects drive-absolute (`C:\...`) and UNC archive paths alongside `..` traversal, and only accepts the binary at the archive root (never a nested entry, never two candidates) - Removes `golang.org/x/crypto/openpgp` from the build graph, clearing advisory GO-2026-5932 (unmaintained package, no fix available). Dropping `ulikunitz/xz` also clears GO-2025-3922 ## [0.7.0] - 2026-04-21 diff --git a/CLAUDE.md b/CLAUDE.md index df3a75a..0fafc82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Project - **Language:** Go 1.25+ - **Module:** `github.com/aaearon/grant-cli` -- **Dependencies:** `github.com/cyberark/idsec-sdk-golang` is the primary dependency; zero-new-Go-module-deps is a goal, not an absolute rule. Documented exception: `github.com/minio/selfupdate` (+ its one transitive `aead.dev/minisign`) for `grant update`, adopted to remove the abandoned `rhysd/go-github-selfupdate` and advisory GO-2026-5932. Net effect was 39 -> 33 modules +- **Dependencies:** `github.com/cyberark/idsec-sdk-golang` is the primary dependency; zero-new-Go-module-deps is a goal, not an absolute rule. Documented exception: `github.com/minio/selfupdate` (+ its one transitive `aead.dev/minisign`) for `grant update`, adopted to remove the abandoned `rhysd/go-github-selfupdate` and advisory GO-2026-5932. Net effect: build graph (`go list -deps`) 39 -> 33 modules, `go.mod` requires 47 -> 39, full module graph (`go list -m all`) 110 -> 95 ## SDK Import Conventions ```go @@ -84,11 +84,12 @@ Custom `SCAAccessService` follows SDK conventions: - Request picker: `internal/ui/request_selector.go` mirrors the role-selector Format/Build/Select quartet; `resolveRequestIDFn` in `cmd/request_picker.go` is injectable for tests. Non-TTY invocation without `` returns `ErrNotInteractive` with a hint to run `grant request list` - `grant update` — self-update binary via GitHub Releases; guards against dev builds. Implemented in `internal/selfupdate/`: - Discovery: `GET https://api.github.com/repos/aaearon/grant-cli/releases/latest` (`apiBaseURL` field injectable for tests) - - Version compare: in-house `MAJOR.MINOR.PATCH` parser (`ParseVersion`/`CompareVersions`) — grant only emits GoReleaser-style tags, so no semver library + - Version compare: in-house SemVer 2.0.0 parser (`ParseVersion`/`CompareVersions`). Handles pre-release and build metadata (GoReleaser can emit both) with SemVer precedence: build metadata ignored for ordering, pre-release sorts before its release. A leading `v`/`V` is tolerated; leading zeroes are rejected - Asset selection: `grant-cli___.tar.gz` (`.zip` on windows) — must stay in sync with `.goreleaser.yaml` - - Integrity: SHA-256 of the archive checked against the release's `checksums.txt`. **Trust model:** `checksums.txt` comes from the same origin as the archive, so it defends against corrupted/tampered downloads in transit, **not** against a compromised GitHub account or release pipeline. Signature verification would be needed for that - - Extraction: `archive/tar`+`compress/gzip` / `archive/zip`, rejecting absolute and `..` paths (gosec G305) and capped at 128 MiB via `io.LimitReader` (gosec G110) - - Apply: `github.com/minio/selfupdate` v0.6.0 owns the atomic replace, fsync, Windows rename dance and rollback — do not hand-roll this. Seam: `applyWithOptions` in `internal/selfupdate/apply.go` + - Integrity: SHA-256 of the archive checked against the release's `checksums.txt` (GNU `*filename` binary marker tolerated). **Trust model:** `checksums.txt` comes from the same origin as the archive, so it defends against corrupted/tampered downloads in transit, **not** against a compromised GitHub account or release pipeline. Signature verification would be needed for that. Note the checksum covers the *archive*, not the extracted binary — hence the independent size checks below + - Extraction: `archive/tar`+`compress/gzip` / `archive/zip`. Rejects absolute, drive-absolute (`C:\`), UNC and `..` paths (gosec G305); accepts only a single `grant`/`grant.exe` at the archive root (nested entries and duplicate candidates are errors). Size cap is 128 MiB (`maxDownloadBytes`), enforced by `readCapped`, which probes one byte past the cap — a bare `io.LimitReader` reports a *successful* short read and would silently install a truncated binary (gosec G110) + - Apply: `github.com/minio/selfupdate` v0.6.0 owns the staged-file write, the two-rename swap including the Windows path, and rollback — do not hand-roll this. grant adds the `fsync` of the staged file (minio does not sync) plus a best-effort directory sync. Seams: `applyWithOptions`, `prepareFn`, `commitFn` in `internal/selfupdate/apply.go` + - **Atomicity, precisely:** each rename is atomic, so the installed binary is never partially written. The *pair* is not: a kill between the two renames, or a failed second rename whose rollback also fails, leaves the binary path absent with `.grant.old`/`.grant.new` beside it. `InterruptedUpdate()` detects that state and `recoveryHint()` prints the `mv` command that fixes it. Do not describe this as fully atomic - `selfUpdater` in `cmd/interfaces.go` is defined over grant-owned types: `UpdateSelf(ctx, current string) (newVersion string, updated bool, err error)` - `--groups` flag on root command shows only Entra ID groups in the interactive selector - `--group` / `-g` flag on root command for direct group membership elevation (`grant --group "Cloud Admins"`) diff --git a/README.md b/README.md index c41b6b5..ddd0736 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ sudo mv grant /usr/local/bin/ grant update ``` -`grant update` checks GitHub Releases for a newer version, verifies the downloaded archive's SHA-256 against the release's `checksums.txt`, and replaces the running binary in place (with rollback on failure). It refuses to run on dev builds — install a release build or download from the releases page. Because `checksums.txt` is published alongside the archive, verification protects against corrupted or tampered downloads, not against a compromised release pipeline. +`grant update` checks GitHub Releases for a newer version, verifies the downloaded archive's SHA-256 against the release's `checksums.txt`, and replaces the running binary in place. It refuses to run on dev builds — install a release build or download from the releases page. Because `checksums.txt` is published alongside the archive, verification protects against corrupted or tampered downloads, not against a compromised release pipeline. + +The replacement stages the new binary next to the old one, flushes it to disk, then swaps it in. Each step is atomic on its own, so you never end up running a half-written binary, and a failed swap rolls back automatically. In the rare case that grant is killed mid-swap, the binary path is left empty with `.grant.old` beside it; grant prints the exact `mv` command to restore it (`mv /path/to/.grant.old /path/to/grant`). **Windows:** Download `grant-cli__windows_.zip` from [releases](https://github.com/aaearon/grant-cli/releases) and extract to a directory in your PATH. diff --git a/internal/selfupdate/apply.go b/internal/selfupdate/apply.go index 4f2d4ec..c9f1d65 100644 --- a/internal/selfupdate/apply.go +++ b/internal/selfupdate/apply.go @@ -6,13 +6,41 @@ import ( "crypto/sha256" "errors" "fmt" + "io" + "os" + "path/filepath" minio "github.com/minio/selfupdate" ) +// Durability and atomicity, precisely: +// +// The replacement is staged as a sibling file, fsync'ed, and then swapped in +// by github.com/minio/selfupdate with two renames: +// +// 1. rename(target, .target.old) +// 2. rename(.target.new, target) +// +// Each rename is individually atomic, so the target is never a partially +// written file. The *pair* is not atomic: if the process is killed between +// step 1 and step 2, or if step 2 and the rollback rename both fail, the +// target path is left ABSENT with .target.old and .target.new beside it. That +// window is small but real, and it cannot be closed without a different +// replacement strategy. When grant detects it, it prints the exact command to +// restore the old binary; see recoveryHint. +// +// The fsync in syncStagedFile is grant's, not minio's: minio writes and closes +// the staged file without syncing it, so a crash or power loss between the +// write and the rename could otherwise leave a zero-length or partially +// materialized .target.new to be renamed into place. + +// applyFns are indirections so tests can drive the failure paths. +var ( + prepareFn = minio.PrepareAndCheckBinary + commitFn = minio.CommitBinary +) + // applyBinary replaces the currently running executable with newBinary. -// The atomic replace, fsync, Windows rename dance and rollback are owned by -// github.com/minio/selfupdate. func applyBinary(newBinary []byte) error { return applyBinaryTo(newBinary, "") } @@ -21,22 +49,113 @@ func applyBinary(newBinary []byte) error { // the running executable. func applyBinaryTo(newBinary []byte, targetPath string) error { sum := sha256.Sum256(newBinary) - return applyWithOptions(newBinary, minio.Options{ + return applyWithOptions(bytes.NewReader(newBinary), minio.Options{ TargetPath: targetPath, - TargetMode: 0o755, + TargetMode: 0o755, // release binaries are always installed executable Hash: crypto.SHA256, Checksum: sum[:], }) } -// applyWithOptions is the single seam onto minio/selfupdate, so tests can -// exercise failure and rollback paths. -func applyWithOptions(newBinary []byte, opts minio.Options) error { - if err := minio.Apply(bytes.NewReader(newBinary), opts); err != nil { - if rollbackErr := minio.RollbackError(err); rollbackErr != nil { - return fmt.Errorf("update failed and rollback also failed, restore the binary manually: %w", errors.Join(err, rollbackErr)) - } +// applyWithOptions stages, syncs and commits the replacement. It is the single +// seam onto minio/selfupdate. +func applyWithOptions(update io.Reader, opts minio.Options) error { + target, err := resolveTargetPath(opts.TargetPath) + if err != nil { + return fmt.Errorf("failed to locate the binary to replace: %w", err) + } + + if err := prepareFn(update, opts); err != nil { return err } + + // minio closes the staged file but never syncs it; do that before any + // rename so a crash cannot promote a partially materialized file. + if err := syncStagedFile(target); err != nil { + _ = os.Remove(stagedPath(target)) + return fmt.Errorf("failed to flush the staged binary to disk: %w", err) + } + + if err := commitFn(opts); err != nil { + return wrapApplyError(err, minio.RollbackError(err), target) + } + + // Best effort: make the rename itself durable. Not supported on every + // platform or filesystem, so failures here are not fatal - the swap has + // already happened. + syncDir(filepath.Dir(target)) return nil } + +// resolveTargetPath mirrors minio's Options.getPath. +func resolveTargetPath(targetPath string) (string, error) { + if targetPath != "" { + return targetPath, nil + } + return os.Executable() +} + +// stagedPath mirrors the staged filename minio writes. +func stagedPath(targetPath string) string { + return filepath.Join(filepath.Dir(targetPath), "."+filepath.Base(targetPath)+".new") +} + +// oldPathFor mirrors the backup filename minio renames the current binary to +// when OldSavePath is not set. +func oldPathFor(targetPath string) string { + return filepath.Join(filepath.Dir(targetPath), "."+filepath.Base(targetPath)+".old") +} + +// syncStagedFile fsyncs the staged replacement binary. +func syncStagedFile(targetPath string) error { + f, err := os.OpenFile(stagedPath(targetPath), os.O_RDWR, 0o600) //nolint:gosec // path derived from the target binary + if err != nil { + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +// syncDir fsyncs a directory so a completed rename survives a crash. Best +// effort: several platforms and filesystems refuse this. +func syncDir(dir string) { + d, err := os.Open(dir) //nolint:gosec // directory of the target binary + if err != nil { + return + } + _ = d.Sync() + _ = d.Close() +} + +// wrapApplyError turns a commit failure into an actionable error. rollbackErr +// is the result of minio.RollbackError(err): non-nil means the original binary +// could not be put back and the user must intervene. +func wrapApplyError(err, rollbackErr error, targetPath string) error { + if rollbackErr == nil { + return err + } + return fmt.Errorf("update failed and the rollback also failed, so %s no longer exists; %s: %w", + targetPath, recoveryHint(targetPath), errors.Join(err, rollbackErr)) +} + +// recoveryHint spells out how to restore the previous binary by hand after an +// interrupted or half-failed update. +func recoveryHint(targetPath string) string { + return fmt.Sprintf("restore it with: mv %q %q", oldPathFor(targetPath), targetPath) +} + +// InterruptedUpdate reports whether targetPath looks like a previous update +// that was interrupted between the two renames - the binary is missing but its +// backup is still there - and returns the command that repairs it. +func InterruptedUpdate(targetPath string) (hint string, interrupted bool) { + if _, err := os.Stat(targetPath); err == nil || !errors.Is(err, os.ErrNotExist) { + return "", false + } + if _, err := os.Stat(oldPathFor(targetPath)); err != nil { + return "", false + } + return recoveryHint(targetPath), true +} diff --git a/internal/selfupdate/apply_test.go b/internal/selfupdate/apply_test.go index 438ce74..c3af7b2 100644 --- a/internal/selfupdate/apply_test.go +++ b/internal/selfupdate/apply_test.go @@ -1,12 +1,15 @@ package selfupdate import ( + "bytes" "crypto" "crypto/sha256" "errors" + "io" "os" "path/filepath" "runtime" + "strings" "testing" minio "github.com/minio/selfupdate" @@ -29,6 +32,26 @@ func writeFakeBinary(t *testing.T) string { return path } +// readFile is a fatal-on-error helper. +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) //nolint:gosec // test fixture + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +// assertNotTruncated pins the core safety property: whatever happens, the +// binary on disk is either the complete old one or the complete new one. +func assertNotTruncated(t *testing.T, path string) { + t.Helper() + got := readFile(t, path) + if got != oldBinaryContents && got != newBinaryContents { + t.Fatalf("binary is neither the old nor the new one (truncated?): %q", got) + } +} + func TestApplyBinaryToReplacesTarget(t *testing.T) { path := writeFakeBinary(t) @@ -36,12 +59,8 @@ func TestApplyBinaryToReplacesTarget(t *testing.T) { t.Fatalf("applyBinaryTo: %v", err) } - got, err := os.ReadFile(path) //nolint:gosec // test fixture - if err != nil { - t.Fatalf("read back: %v", err) - } - if string(got) != newBinaryContents { - t.Errorf("target contents = %q, want %q", string(got), newBinaryContents) + if got := readFile(t, path); got != newBinaryContents { + t.Errorf("target contents = %q, want %q", got, newBinaryContents) } if runtime.GOOS != "windows" { @@ -49,16 +68,29 @@ func TestApplyBinaryToReplacesTarget(t *testing.T) { if err != nil { t.Fatalf("stat: %v", err) } + // The installed mode is always 0755: release archives carry the + // binary and grant deliberately ignores archive modes. if perm := info.Mode().Perm(); perm != 0o755 { t.Errorf("mode = %o, want 0755", perm) } } + + if _, err := os.Stat(stagedPath(path)); err == nil { + t.Error("staged .new file was left behind after a successful apply") + } + if _, err := os.Stat(oldPathFor(path)); err == nil { + t.Error("backup .old file was left behind after a successful apply") + } } -func TestApplyBinaryFailureLeavesOriginalIntact(t *testing.T) { +// TestApplyBinaryFailsBeforeTouchingTarget covers the failures that happen +// while staging, i.e. before the original binary is renamed at all. +func TestApplyBinaryFailsBeforeTouchingTarget(t *testing.T) { tests := []struct { - name string - opts func(target string) minio.Options + name string + update func() io.Reader + opts func(target string) minio.Options + setup func(t *testing.T, target string) }{ { name: "checksum mismatch", @@ -72,24 +104,39 @@ func TestApplyBinaryFailureLeavesOriginalIntact(t *testing.T) { }, }, { - name: "checksum mismatch with rollback path", + name: "target directory does not exist", opts: func(target string) minio.Options { return minio.Options{ - TargetPath: target, - TargetMode: 0o755, - OldSavePath: target + ".old", - Hash: crypto.SHA256, - Checksum: make([]byte, sha256.Size), + TargetPath: filepath.Join(target, "no", "such", "dir", "grant"), + TargetMode: 0o755, } }, }, { - name: "target directory does not exist", + name: "partial staged write: source reader fails mid-stream", + update: func() io.Reader { + return io.MultiReader( + strings.NewReader(newBinaryContents[:10]), + &failingReader{err: errTestApply}, + ) + }, opts: func(target string) minio.Options { - return minio.Options{ - TargetPath: filepath.Join(target, "no", "such", "dir", "grant"), - TargetMode: 0o755, + return minio.Options{TargetPath: target, TargetMode: 0o755} + }, + }, + { + name: "unwritable target directory", + setup: func(t *testing.T, target string) { + if os.Geteuid() == 0 { + t.Skip("running as root: directory permissions are not enforced") } + if err := os.Chmod(filepath.Dir(target), 0o500); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(filepath.Dir(target), 0o700) }) + }, + opts: func(target string) minio.Options { + return minio.Options{TargetPath: target, TargetMode: 0o755} }, }, } @@ -97,75 +144,247 @@ func TestApplyBinaryFailureLeavesOriginalIntact(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { path := writeFakeBinary(t) - - err := applyWithOptions([]byte(newBinaryContents), tt.opts(path)) - if err == nil { - t.Fatal("expected error, got nil") + if tt.setup != nil { + tt.setup(t, path) } - got, readErr := os.ReadFile(path) //nolint:gosec // test fixture - if readErr != nil { - t.Fatalf("original binary is gone: %v", readErr) - } - // The binary must be either the untouched original or the fully - // written replacement - never a truncated hybrid. - if s := string(got); s != oldBinaryContents && s != newBinaryContents { - t.Errorf("binary is neither old nor new (possibly truncated): %q", s) + var update io.Reader = bytes.NewReader([]byte(newBinaryContents)) + if tt.update != nil { + update = tt.update() } - if string(got) != oldBinaryContents { - t.Errorf("expected original contents preserved, got %q", string(got)) + + if err := applyWithOptions(update, tt.opts(path)); err == nil { + t.Fatal("expected error, got nil") } - // No stray .new file left behind next to the target. - if _, statErr := os.Stat(path + ".new"); statErr == nil { + assertNotTruncated(t, path) + if got := readFile(t, path); got != oldBinaryContents { + t.Errorf("expected original contents preserved, got %q", got) + } + if _, err := os.Stat(stagedPath(path)); err == nil { t.Error("leftover .new file was not cleaned up") } }) } } -func TestApplyBinaryRollbackRestoresOriginal(t *testing.T) { +// TestApplyBinaryRollsBackWhenSecondRenameFails drives the real rollback path: +// the current binary has already been renamed away when the second rename +// fails, and minio must put it back. +func TestApplyBinaryRollsBackWhenSecondRenameFails(t *testing.T) { path := writeFakeBinary(t) - oldSave := path + ".old" - // A successful apply moves the original aside to OldSavePath. - err := applyWithOptions([]byte(newBinaryContents), minio.Options{ - TargetPath: path, - TargetMode: 0o755, - OldSavePath: oldSave, + // Stage normally, then delete the staged file so that minio's second + // rename (.grant.new -> grant) fails after the first has succeeded. + origCommit := commitFn + defer func() { commitFn = origCommit }() + commitFn = func(opts minio.Options) error { + if err := os.Remove(stagedPath(path)); err != nil { + t.Fatalf("remove staged file: %v", err) + } + return origCommit(opts) + } + + err := applyWithOptions(bytes.NewReader([]byte(newBinaryContents)), minio.Options{ + TargetPath: path, + TargetMode: 0o755, }) - if err != nil { - t.Fatalf("apply: %v", err) + if err == nil { + t.Fatal("expected the commit to fail, got nil") } - saved, err := os.ReadFile(oldSave) //nolint:gosec // test fixture - if err != nil { - t.Fatalf("old binary was not saved for rollback: %v", err) + // Rollback succeeded, so the error must NOT claim otherwise. + if minio.RollbackError(err) != nil { + t.Errorf("rollback unexpectedly failed: %v", minio.RollbackError(err)) + } + if strings.Contains(err.Error(), "no longer exists") { + t.Errorf("error wrongly reports an unrecoverable state: %v", err) + } + + assertNotTruncated(t, path) + if got := readFile(t, path); got != oldBinaryContents { + t.Errorf("rollback did not restore the original: got %q", got) + } + if _, err := os.Stat(oldPathFor(path)); err == nil { + t.Error(".old backup should have been renamed back, not left in place") + } +} + +// TestApplyBinaryRollbackFailureIsReported covers the unrecoverable case: the +// commit failed AND the rollback failed, so the target no longer exists. +func TestApplyBinaryRollbackFailureIsReported(t *testing.T) { + path := writeFakeBinary(t) + + origCommit := commitFn + defer func() { commitFn = origCommit }() + commitFn = func(opts minio.Options) error { + // Reproduce the exact end state minio leaves behind: the binary has + // been renamed to its backup and neither the new file nor the + // rollback could take its place. + if err := os.Rename(path, oldPathFor(path)); err != nil { + t.Fatalf("simulate first rename: %v", err) + } + if err := os.Remove(stagedPath(path)); err != nil { + t.Fatalf("remove staged file: %v", err) + } + return errTestApply + } + + err := applyWithOptions(bytes.NewReader([]byte(newBinaryContents)), minio.Options{ + TargetPath: path, + TargetMode: 0o755, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + + // The target is genuinely gone; the user must be told exactly how to fix it. + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected the target to be absent, stat gave: %v", statErr) + } + hint, interrupted := InterruptedUpdate(path) + if !interrupted { + t.Fatal("InterruptedUpdate did not detect the interrupted state") } - if string(saved) != oldBinaryContents { - t.Errorf("saved old binary = %q, want %q", string(saved), oldBinaryContents) + if !strings.Contains(hint, oldPathFor(path)) || !strings.Contains(hint, path) { + t.Errorf("recovery hint does not name both paths: %q", hint) } - // Simulate the user rolling back after a bad update. - if err := minio.RollbackError(errTestApply); err != nil && !errors.Is(err, errTestApply) { - t.Fatalf("unexpected rollback error: %v", err) + // And the documented recovery actually works. + if err := os.Rename(oldPathFor(path), path); err != nil { + t.Fatalf("documented recovery failed: %v", err) + } + if got := readFile(t, path); got != oldBinaryContents { + t.Errorf("recovered binary = %q, want the original", got) } } -func TestApplyBinaryVerifiesWhatItWrote(t *testing.T) { +// TestWrapApplyError pins the message contract, including the rollback-failed +// wording that cannot be provoked deterministically through minio itself. +func TestWrapApplyError(t *testing.T) { + rollbackFailed := errors.New("rename back failed") + + t.Run("rollback succeeded returns the original error", func(t *testing.T) { + got := wrapApplyError(errTestApply, nil, "/usr/local/bin/grant") + if !errors.Is(got, errTestApply) { + t.Errorf("error does not wrap the original: %v", got) + } + if strings.Contains(got.Error(), "no longer exists") { + t.Errorf("unexpected unrecoverable wording: %v", got) + } + }) + + t.Run("rollback failed reports recovery", func(t *testing.T) { + got := wrapApplyError(errTestApply, rollbackFailed, "/usr/local/bin/grant") + if !errors.Is(got, errTestApply) || !errors.Is(got, rollbackFailed) { + t.Errorf("error must wrap both causes: %v", got) + } + for _, want := range []string{"/usr/local/bin/grant", ".grant.old", "mv"} { + if !strings.Contains(got.Error(), want) { + t.Errorf("message missing %q: %v", want, got) + } + } + }) +} + +// TestInterruptedUpdate covers detection of a process killed between the two +// renames, and the cases that must NOT be reported as interrupted. +func TestInterruptedUpdate(t *testing.T) { + t.Run("target missing with backup present", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "grant") + if err := os.WriteFile(oldPathFor(path), []byte(oldBinaryContents), 0o755); err != nil { //nolint:gosec // test fixture + t.Fatalf("write backup: %v", err) + } + if _, ok := InterruptedUpdate(path); !ok { + t.Error("expected the interrupted state to be detected") + } + }) + + t.Run("healthy install", func(t *testing.T) { + path := writeFakeBinary(t) + if hint, ok := InterruptedUpdate(path); ok { + t.Errorf("healthy install reported as interrupted: %q", hint) + } + }) + + t.Run("target missing without backup", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "grant") + if _, ok := InterruptedUpdate(path); ok { + t.Error("missing binary without a backup must not be reported as interrupted") + } + }) +} + +// TestApplyBinaryStagedFileIsSynced pins that grant syncs the staged file +// before any rename - minio does not. +func TestApplyBinaryStagedFileIsSynced(t *testing.T) { path := writeFakeBinary(t) + synced := false + origCommit := commitFn + defer func() { commitFn = origCommit }() + commitFn = func(opts minio.Options) error { + // By commit time the staged file must exist and hold the full + // replacement; syncStagedFile ran just before this. + staged, err := os.ReadFile(stagedPath(path)) //nolint:gosec // test fixture + if err != nil { + t.Fatalf("staged file missing at commit time: %v", err) + } + if string(staged) != newBinaryContents { + t.Errorf("staged file = %q, want the full replacement", string(staged)) + } + synced = true + return origCommit(opts) + } + if err := applyBinaryTo([]byte(newBinaryContents), path); err != nil { t.Fatalf("applyBinaryTo: %v", err) } + if !synced { + t.Error("commit step never ran") + } +} + +// TestApplyBinaryToSymlink documents what happens when the install path is a +// symlink: the link itself is replaced by a regular file and the link target +// is left untouched. On Linux and macOS os.Executable resolves symlinks, so +// the real run replaces the resolved binary, not the link. +func TestApplyBinaryToSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink semantics differ on Windows and are untested here") + } + + dir := t.TempDir() + targetFile := filepath.Join(dir, "grant-real") + link := filepath.Join(dir, "grant") + if err := os.WriteFile(targetFile, []byte(oldBinaryContents), 0o755); err != nil { //nolint:gosec // test fixture + t.Fatalf("write real binary: %v", err) + } + if err := os.Symlink(targetFile, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if err := applyBinaryTo([]byte(newBinaryContents), link); err != nil { + t.Fatalf("applyBinaryTo: %v", err) + } - got, err := os.ReadFile(path) //nolint:gosec // test fixture + if got := readFile(t, link); got != newBinaryContents { + t.Errorf("link path contents = %q, want the replacement", got) + } + if got := readFile(t, targetFile); got != oldBinaryContents { + t.Errorf("symlink target was modified: %q", got) + } + info, err := os.Lstat(link) if err != nil { - t.Fatalf("read back: %v", err) + t.Fatalf("lstat: %v", err) } - sum := sha256.Sum256(got) - want := sha256.Sum256([]byte(newBinaryContents)) - if sum != want { - t.Error("written binary does not match the source bytes") + if info.Mode()&os.ModeSymlink != 0 { + t.Error("expected the symlink to have been replaced by a regular file") } } + +// failingReader returns an error after being read from. +type failingReader struct{ err error } + +func (f *failingReader) Read([]byte) (int, error) { return 0, f.err } diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index 1c9ba62..1c6aaf9 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -39,13 +39,14 @@ const ( checksumsFile = "checksums.txt" // defaultAPIBaseURL is the GitHub REST API root. defaultAPIBaseURL = "https://api.github.com" - // maxDownloadBytes caps every download and every decompressed entry, - // guarding against decompression bombs (gosec G110). - maxDownloadBytes = 128 << 20 // 128 MiB // downloadTimeout bounds the whole update operation. downloadTimeout = 5 * time.Minute ) +// maxDownloadBytes caps every download and every decompressed entry, guarding +// against decompression bombs (gosec G110). A variable so tests can shrink it. +var maxDownloadBytes int64 = 128 << 20 // 128 MiB + // httpDoer allows injecting a stub transport in tests. type httpDoer interface { Do(req *http.Request) (*http.Response, error) @@ -171,7 +172,7 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (*ghRelease, error) { } defer resp.Body.Close() //nolint:errcheck // nothing actionable on close - body, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadBytes)) + body, err := readCapped(resp.Body, maxDownloadBytes, "GitHub release response") if err != nil { return nil, fmt.Errorf("failed to read GitHub response: %w", err) } @@ -207,7 +208,7 @@ func (u *Updater) download(ctx context.Context, url string) ([]byte, error) { return nil, fmt.Errorf("download returned status %d", resp.StatusCode) } - data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadBytes)) + data, err := readCapped(resp.Body, maxDownloadBytes, "download") if err != nil { return nil, err } @@ -217,6 +218,30 @@ func (u *Updater) download(ctx context.Context, url string) ([]byte, error) { return data, nil } +// readCapped reads at most limit bytes and fails if the source has more to +// give. io.LimitReader alone reports a *successful* short read when the input +// is longer than the cap, which would silently truncate a binary; probing for +// one extra byte turns that into an error (gosec G110 without the silent +// truncation hazard). +func readCapped(r io.Reader, limit int64, what string) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, limit)) + if err != nil { + return nil, err + } + if int64(len(data)) < limit { + return data, nil + } + var probe [1]byte + switch n, err := io.ReadFull(r, probe[:]); { + case n > 0: + return nil, fmt.Errorf("%s exceeds the %d byte limit", what, limit) + case err == nil, errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF): + return data, nil + default: + return nil, err + } +} + // assetNameFor builds the GoReleaser archive name for a platform. // Mirrors .goreleaser.yaml: default archive name_template with a zip override // on Windows. @@ -242,7 +267,8 @@ func verifyChecksum(checksums []byte, filename string, data []byte) error { if len(fields) != 2 { return fmt.Errorf("malformed line in %s: %q", checksumsFile, line) } - if fields[1] == filename { + // GNU coreutils marks binary-mode entries with a leading "*". + if strings.TrimPrefix(fields[1], "*") == filename { want = fields[0] break } @@ -275,20 +301,51 @@ func extractBinary(archive []byte, assetName string) ([]byte, error) { } // isBinaryEntry reports whether an archive entry is the grant executable. +// GoReleaser places the binary at the archive root, so nested entries with the +// same basename are deliberately NOT accepted - matching them would let a +// crafted archive smuggle a different file into the install. func isBinaryEntry(name string) bool { - base := path.Base(name) - return base == binaryName || base == binaryName+".exe" + cleaned := path.Clean(strings.ReplaceAll(name, `\`, "/")) + cleaned = strings.TrimPrefix(cleaned, "./") + if strings.Contains(cleaned, "/") { + return false + } + return cleaned == binaryName || cleaned == binaryName+".exe" } -// checkArchivePath rejects absolute paths and traversal (gosec G305). +// checkArchivePath rejects absolute paths, drive-absolute Windows paths, UNC +// paths and traversal (gosec G305). Extraction is in-memory, so none of these +// are currently exploitable, but the archive is untrusted input and the +// documented guarantee has to actually hold. func checkArchivePath(name string) error { - cleaned := path.Clean(strings.ReplaceAll(name, `\`, "/")) - if path.IsAbs(cleaned) || strings.HasPrefix(cleaned, "../") || cleaned == ".." { - return fmt.Errorf("archive contains illegal path %q", name) + normalized := strings.ReplaceAll(name, `\`, "/") + cleaned := path.Clean(normalized) + + switch { + case name == "": + return errors.New("archive contains an entry with an empty name") + case path.IsAbs(cleaned): + return fmt.Errorf("archive contains illegal absolute path %q", name) + case strings.HasPrefix(normalized, "//"): + return fmt.Errorf("archive contains illegal UNC path %q", name) + case hasDriveLetter(normalized): + return fmt.Errorf("archive contains illegal drive-absolute path %q", name) + case cleaned == ".." || strings.HasPrefix(cleaned, "../"): + return fmt.Errorf("archive contains illegal path traversal %q", name) } return nil } +// hasDriveLetter reports whether name starts with a Windows drive designator +// such as "C:" - path.IsAbs does not consider these absolute. +func hasDriveLetter(name string) bool { + if len(name) < 2 || name[1] != ':' { + return false + } + c := name[0] + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + func extractFromTarGz(archive []byte) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewReader(archive)) if err != nil { @@ -296,6 +353,7 @@ func extractFromTarGz(archive []byte) ([]byte, error) { } defer gz.Close() //nolint:errcheck // read-only stream + var found []byte tr := tar.NewReader(gz) for { hdr, err := tr.Next() @@ -311,13 +369,25 @@ func extractFromTarGz(archive []byte) ([]byte, error) { if hdr.Typeflag != tar.TypeReg || !isBinaryEntry(hdr.Name) { continue } - data, err := io.ReadAll(io.LimitReader(tr, maxDownloadBytes)) // gosec G110 + if found != nil { + return nil, fmt.Errorf("archive contains more than one %s binary", binaryName) + } + if hdr.Size > maxDownloadBytes { + return nil, fmt.Errorf("%s in archive declares %d bytes, over the %d byte limit", hdr.Name, hdr.Size, maxDownloadBytes) + } + data, err := readCapped(tr, maxDownloadBytes, hdr.Name) // gosec G110 if err != nil { return nil, fmt.Errorf("failed to read %s from archive: %w", hdr.Name, err) } - return data, nil + if int64(len(data)) != hdr.Size { + return nil, fmt.Errorf("%s in archive is truncated: got %d bytes, header declares %d", hdr.Name, len(data), hdr.Size) + } + found = data } - return nil, fmt.Errorf("archive does not contain a %s binary", binaryName) + if found == nil { + return nil, fmt.Errorf("archive does not contain a %s binary", binaryName) + } + return found, nil } func extractFromZip(archive []byte) ([]byte, error) { @@ -326,6 +396,7 @@ func extractFromZip(archive []byte) ([]byte, error) { return nil, fmt.Errorf("failed to open zip archive: %w", err) } + var found []byte for _, f := range zr.File { if err := checkArchivePath(f.Name); err != nil { return nil, err @@ -333,19 +404,38 @@ func extractFromZip(archive []byte) ([]byte, error) { if f.FileInfo().IsDir() || !isBinaryEntry(f.Name) { continue } - rc, err := f.Open() - if err != nil { - return nil, fmt.Errorf("failed to open %s in archive: %w", f.Name, err) + if found != nil { + return nil, fmt.Errorf("archive contains more than one %s binary", binaryName) + } + if maxDownloadBytes >= 0 && f.UncompressedSize64 > uint64(maxDownloadBytes) { //nolint:gosec // maxDownloadBytes is a positive constant-derived cap + return nil, fmt.Errorf("%s in archive declares %d bytes, over the %d byte limit", f.Name, f.UncompressedSize64, maxDownloadBytes) } - data, err := io.ReadAll(io.LimitReader(rc, maxDownloadBytes)) // gosec G110 - closeErr := rc.Close() + data, err := readZipEntry(f) if err != nil { - return nil, fmt.Errorf("failed to read %s from archive: %w", f.Name, err) + return nil, err } - if closeErr != nil { - return nil, fmt.Errorf("failed to close %s in archive: %w", f.Name, closeErr) + if uint64(len(data)) != f.UncompressedSize64 { + return nil, fmt.Errorf("%s in archive is truncated: got %d bytes, directory declares %d", f.Name, len(data), f.UncompressedSize64) } - return data, nil + found = data } - return nil, fmt.Errorf("archive does not contain a %s binary", binaryName) + if found == nil { + return nil, fmt.Errorf("archive does not contain a %s binary", binaryName) + } + return found, nil +} + +// readZipEntry reads a single zip entry with the decompression cap applied. +func readZipEntry(f *zip.File) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("failed to open %s in archive: %w", f.Name, err) + } + defer rc.Close() //nolint:errcheck // read-only stream + + data, err := readCapped(rc, maxDownloadBytes, f.Name) // gosec G110 + if err != nil { + return nil, fmt.Errorf("failed to read %s from archive: %w", f.Name, err) + } + return data, nil } diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index 8955fec..a49586c 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -174,6 +174,18 @@ func TestVerifyChecksum(t *testing.T) { filename: "grant-cli_0.7.0_linux_amd64.tar.gz", data: payload, }, + { + name: "GNU binary mode marker", + checksums: good + " *grant-cli_0.7.0_linux_amd64.tar.gz\n", + filename: "grant-cli_0.7.0_linux_amd64.tar.gz", + data: payload, + }, + { + name: "CRLF line endings", + checksums: good + " grant-cli_0.7.0_linux_amd64.tar.gz\r\n", + filename: "grant-cli_0.7.0_linux_amd64.tar.gz", + data: payload, + }, { name: "mismatch", checksums: "0000000000000000000000000000000000000000000000000000000000000000 grant-cli_0.7.0_linux_amd64.tar.gz\n", @@ -316,6 +328,58 @@ func TestExtractBinary(t *testing.T) { }), wantErr: true, }, + { + name: "tar.gz windows drive-absolute path rejected", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: buildTarGz(t, [][2]string{ + {`C:\grant.exe`, "pwned"}, + }), + wantErr: true, + }, + { + name: "zip windows drive-absolute path rejected", + assetName: "grant-cli_0.7.0_windows_amd64.zip", + archive: buildZip(t, [][2]string{ + {`C:\grant.exe`, "pwned"}, + }), + wantErr: true, + }, + { + name: "tar.gz UNC path rejected", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: buildTarGz(t, [][2]string{{"//host/share/grant", "pwned"}}), + wantErr: true, + }, + { + name: "nested binary is not selected", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: buildTarGz(t, [][2]string{{"nested/dir/grant", "pwned"}}), + wantErr: true, + }, + { + name: "dot-slash prefixed binary is accepted", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: buildTarGz(t, [][2]string{{"./grant", binContents}}), + want: binContents, + }, + { + name: "two candidate binaries rejected", + assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", + archive: buildTarGz(t, [][2]string{ + {"grant", binContents}, + {"grant.exe", "a different binary"}, + }), + wantErr: true, + }, + { + name: "zip with two candidate binaries rejected", + assetName: "grant-cli_0.7.0_windows_amd64.zip", + archive: buildZip(t, [][2]string{ + {"grant.exe", binContents}, + {"grant", "a different binary"}, + }), + wantErr: true, + }, { name: "tar.gz without binary", assetName: "grant-cli_0.7.0_linux_amd64.tar.gz", @@ -500,3 +564,179 @@ func TestUpdateSelf(t *testing.T) { } }) } + +// withSmallCap shrinks the download/decompression cap for a test. +func withSmallCap(t *testing.T, limit int64) { + t.Helper() + orig := maxDownloadBytes + maxDownloadBytes = limit + t.Cleanup(func() { maxDownloadBytes = orig }) +} + +// TestReadCappedDetectsOversize pins the boundary bug io.LimitReader hides: a +// source longer than the cap must be an error, never a successful short read. +func TestReadCappedDetectsOversize(t *testing.T) { + tests := []struct { + name string + input string + limit int64 + wantErr bool + want string + }{ + {name: "under the limit", input: "abcd", limit: 8, want: "abcd"}, + {name: "exactly at the limit", input: "abcdefgh", limit: 8, want: "abcdefgh"}, + {name: "one byte over the limit", input: "abcdefghi", limit: 8, wantErr: true}, + {name: "far over the limit", input: strings.Repeat("x", 1000), limit: 8, wantErr: true}, + {name: "empty", input: "", limit: 8, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := readCapped(strings.NewReader(tt.input), tt.limit, "test input") + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %d bytes", len(got)) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != tt.want { + t.Errorf("got %q, want %q", string(got), tt.want) + } + }) + } +} + +// TestExtractBinaryRejectsOversizedEntry covers the critical case: a +// checksum-valid archive whose *decompressed* binary exceeds the cap must be +// rejected, not silently truncated and installed. +func TestExtractBinaryRejectsOversizedEntry(t *testing.T) { + withSmallCap(t, 64) + big := strings.Repeat("A", 300) + + t.Run("tar.gz", func(t *testing.T) { + archive := buildTarGz(t, [][2]string{{"grant", big}}) + got, err := extractBinary(archive, "grant-cli_0.7.0_linux_amd64.tar.gz") + if err == nil { + t.Fatalf("expected error, got %d bytes", len(got)) + } + if !strings.Contains(err.Error(), "limit") { + t.Errorf("error should mention the limit: %v", err) + } + }) + + t.Run("zip", func(t *testing.T) { + archive := buildZip(t, [][2]string{{"grant.exe", big}}) + got, err := extractBinary(archive, "grant-cli_0.7.0_windows_amd64.zip") + if err == nil { + t.Fatalf("expected error, got %d bytes", len(got)) + } + if !strings.Contains(err.Error(), "limit") { + t.Errorf("error should mention the limit: %v", err) + } + }) + + t.Run("entry exactly at the limit still extracts", func(t *testing.T) { + exact := strings.Repeat("A", 64) + archive := buildTarGz(t, [][2]string{{"grant", exact}}) + got, err := extractBinary(archive, "grant-cli_0.7.0_linux_amd64.tar.gz") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != exact { + t.Errorf("extracted %d bytes, want %d", len(got), len(exact)) + } + }) +} + +// TestExtractBinaryRejectsTruncatedEntry covers a tar whose header declares +// more bytes than the stream actually carries. +func TestExtractBinaryRejectsTruncatedEntry(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + body := strings.Repeat("B", 4096) + if err := tw.WriteHeader(&tar.Header{Name: "grant", Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("write header: %v", err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatalf("write body: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + + // Chop the gzip stream mid-entry so the declared 4096 bytes are not there. + truncated := buf.Bytes()[:buf.Len()/2] + if _, err := extractBinary(truncated, "grant-cli_0.7.0_linux_amd64.tar.gz"); err == nil { + t.Fatal("expected an error for a truncated archive, got nil") + } +} + +// TestDownloadRejectsOversizeBody pins the same boundary on the HTTP path. +func TestDownloadRejectsOversizeBody(t *testing.T) { + withSmallCap(t, 32) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, strings.Repeat("x", 4096)) + })) + defer srv.Close() + + u := New("aaearon/grant-cli") + if _, err := u.download(t.Context(), srv.URL); err == nil { + t.Fatal("expected an error for an oversized download, got nil") + } +} + +// TestUpdateSelfRejectsOversizedBinary is the end-to-end guard: the archive +// checksum is valid, but the binary inside is too big, so nothing is applied. +func TestUpdateSelfRejectsOversizedBinary(t *testing.T) { + withSmallCap(t, 128) + + archiveName := "grant-cli_0.7.0_linux_amd64.tar.gz" + archive := buildTarGz(t, [][2]string{{"grant", strings.Repeat("A", 4096)}}) + srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) + + u := New("aaearon/grant-cli") + u.apiBaseURL = srv.URL + u.goos, u.goarch = "linux", "amd64" + + applied := false + u.applyFn = func(b []byte) error { applied = true; return nil } + + if _, _, err := u.UpdateSelf(t.Context(), "0.6.1"); err == nil { + t.Fatal("expected an error for an oversized binary, got nil") + } + if applied { + t.Error("an oversized binary must never reach the apply step") + } +} + +// TestUpdateSelfPrereleaseCanUpdate is the regression guard for the parser: a +// pre-release build must be able to update to the release. +func TestUpdateSelfPrereleaseCanUpdate(t *testing.T) { + archiveName := "grant-cli_0.7.0_linux_amd64.tar.gz" + archive := buildTarGz(t, [][2]string{{"grant", "new binary"}}) + srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) + + u := New("aaearon/grant-cli") + u.apiBaseURL = srv.URL + u.goos, u.goarch = "linux", "amd64" + u.applyFn = func(b []byte) error { return nil } + + latest, updated, err := u.UpdateSelf(t.Context(), "0.7.0-rc.1") + if err != nil { + t.Fatalf("a pre-release build must be able to update: %v", err) + } + if !updated { + t.Error("expected the pre-release to update to the release") + } + if latest != "0.7.0" { + t.Errorf("latest = %q, want 0.7.0", latest) + } +} diff --git a/internal/selfupdate/version.go b/internal/selfupdate/version.go index dd7b056..f35b791 100644 --- a/internal/selfupdate/version.go +++ b/internal/selfupdate/version.go @@ -6,21 +6,39 @@ import ( "strings" ) -// Version is a GoReleaser-style MAJOR.MINOR.PATCH version. -// grant only ever emits tags in this shape, so pre-release and build -// metadata are deliberately unsupported. +// Version is a Semantic Versioning 2.0.0 version. +// +// GoReleaser normally emits plain MAJOR.MINOR.PATCH tags, but it can also +// produce pre-release (`1.2.3-rc.1`) and build-metadata (`1.2.3+build.5`) +// tags, and grant must be able to update from such a build. Ordering follows +// SemVer precedence: build metadata is ignored, and a pre-release sorts +// before its corresponding release. type Version struct { Major int Minor int Patch int + // Prerelease is the dot-separated pre-release string without the leading + // "-" (empty when the version is a normal release). + Prerelease string + // Build is the build metadata without the leading "+". It never affects + // ordering. + Build string } // String renders the version without a leading "v". func (v Version) String() string { - return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) + s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) + if v.Prerelease != "" { + s += "-" + v.Prerelease + } + if v.Build != "" { + s += "+" + v.Build + } + return s } -// Compare returns -1 if v < other, 0 if equal, 1 if v > other. +// Compare returns -1 if v < other, 0 if they have equal precedence, and 1 if +// v > other. Build metadata is ignored, per SemVer 2.0.0 §10. func (v Version) Compare(other Version) int { for _, pair := range [3][2]int{ {v.Major, other.Major}, @@ -34,33 +52,182 @@ func (v Version) Compare(other Version) int { return 1 } } + return comparePrerelease(v.Prerelease, other.Prerelease) +} + +// comparePrerelease implements SemVer 2.0.0 §11.3-11.4. +func comparePrerelease(a, b string) int { + switch { + case a == b: + return 0 + case a == "": // a release outranks any pre-release of the same core version + return 1 + case b == "": + return -1 + } + + aParts := strings.Split(a, ".") + bParts := strings.Split(b, ".") + for i := 0; i < len(aParts) && i < len(bParts); i++ { + if c := comparePrereleaseIdentifier(aParts[i], bParts[i]); c != 0 { + return c + } + } + + // All shared identifiers are equal: the larger set has higher precedence. + switch { + case len(aParts) < len(bParts): + return -1 + case len(aParts) > len(bParts): + return 1 + } return 0 } -// ParseVersion parses a MAJOR.MINOR.PATCH version, tolerating a leading "v". +// comparePrereleaseIdentifier compares one dot-separated pre-release +// identifier. Numeric identifiers compare numerically and always rank lower +// than alphanumeric ones, which compare in ASCII order. +func comparePrereleaseIdentifier(a, b string) int { + aNum, aIsNum := parseNumericIdentifier(a) + bNum, bIsNum := parseNumericIdentifier(b) + + switch { + case aIsNum && bIsNum: + switch { + case aNum < bNum: + return -1 + case aNum > bNum: + return 1 + } + return 0 + case aIsNum: + return -1 + case bIsNum: + return 1 + } + return strings.Compare(a, b) +} + +// parseNumericIdentifier reports whether s is a numeric identifier and, if so, +// its value. +func parseNumericIdentifier(s string) (int, bool) { + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return 0, false + } + if !isAllDigits(s) { + return 0, false + } + return n, true +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// hasLeadingZero reports whether a numeric identifier violates SemVer's +// no-leading-zeroes rule. +func hasLeadingZero(s string) bool { + return len(s) > 1 && s[0] == '0' +} + +// isValidIdentifierChars reports whether s consists only of SemVer's allowed +// identifier characters: [0-9A-Za-z-]. +func isValidIdentifierChars(s string) bool { + for _, r := range s { + switch { + case r >= '0' && r <= '9', + r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r == '-': + default: + return false + } + } + return true +} + +// ParseVersion parses a SemVer 2.0.0 version. A leading "v" or "V" is +// tolerated, since Git tags conventionally carry one and GitHub reports the +// tag verbatim. func ParseVersion(s string) (Version, error) { - trimmed := strings.TrimPrefix(strings.TrimSpace(s), "v") - parts := strings.Split(trimmed, ".") + rest := strings.TrimSpace(s) + if rest != "" && (rest[0] == 'v' || rest[0] == 'V') { + rest = rest[1:] + } + + var v Version + + // Split off build metadata first: it may itself contain "-". + if idx := strings.IndexByte(rest, '+'); idx >= 0 { + v.Build = rest[idx+1:] + rest = rest[:idx] + if err := validateDotSeparated(v.Build, "build metadata", false); err != nil { + return Version{}, fmt.Errorf("invalid version %q: %w", s, err) + } + } + + // Then the pre-release, which starts at the first "-" after the core. + if idx := strings.IndexByte(rest, '-'); idx >= 0 { + v.Prerelease = rest[idx+1:] + rest = rest[:idx] + if err := validateDotSeparated(v.Prerelease, "pre-release", true); err != nil { + return Version{}, fmt.Errorf("invalid version %q: %w", s, err) + } + } + + parts := strings.Split(rest, ".") if len(parts) != 3 { return Version{}, fmt.Errorf("invalid version %q: expected MAJOR.MINOR.PATCH", s) } - var v Version targets := [3]*int{&v.Major, &v.Minor, &v.Patch} for i, part := range parts { + if !isAllDigits(part) { + return Version{}, fmt.Errorf("invalid version %q: %q is not a non-negative integer", s, part) + } + if hasLeadingZero(part) { + return Version{}, fmt.Errorf("invalid version %q: %q has a leading zero", s, part) + } n, err := strconv.Atoi(part) if err != nil { return Version{}, fmt.Errorf("invalid version %q: %w", s, err) } - if n < 0 { - return Version{}, fmt.Errorf("invalid version %q: negative component", s) - } *targets[i] = n } return v, nil } -// CompareVersions parses both versions and compares them. +// validateDotSeparated validates a pre-release or build-metadata string. +// Numeric pre-release identifiers may not carry leading zeroes; build +// metadata identifiers may. +func validateDotSeparated(s, what string, checkLeadingZero bool) error { + if s == "" { + return fmt.Errorf("empty %s", what) + } + for _, ident := range strings.Split(s, ".") { + if ident == "" { + return fmt.Errorf("empty identifier in %s", what) + } + if !isValidIdentifierChars(ident) { + return fmt.Errorf("illegal character in %s identifier %q", what, ident) + } + if checkLeadingZero && isAllDigits(ident) && hasLeadingZero(ident) { + return fmt.Errorf("numeric %s identifier %q has a leading zero", what, ident) + } + } + return nil +} + +// CompareVersions parses both versions and compares them by SemVer precedence. func CompareVersions(a, b string) (int, error) { va, err := ParseVersion(a) if err != nil { diff --git a/internal/selfupdate/version_test.go b/internal/selfupdate/version_test.go index c7d1bce..4fc3840 100644 --- a/internal/selfupdate/version_test.go +++ b/internal/selfupdate/version_test.go @@ -1,6 +1,10 @@ package selfupdate -import "testing" +import ( + "math" + "strconv" + "testing" +) func TestParseVersion(t *testing.T) { tests := []struct { @@ -10,16 +14,38 @@ func TestParseVersion(t *testing.T) { wantErr bool }{ {name: "plain", input: "1.2.3", want: Version{Major: 1, Minor: 2, Patch: 3}}, - {name: "v prefix", input: "v1.2.3", want: Version{Major: 1, Minor: 2, Patch: 3}}, + {name: "lowercase v prefix", input: "v1.2.3", want: Version{Major: 1, Minor: 2, Patch: 3}}, + {name: "uppercase V prefix", input: "V1.2.3", want: Version{Major: 1, Minor: 2, Patch: 3}}, {name: "zeroes", input: "0.0.0", want: Version{}}, {name: "double digit", input: "1.10.0", want: Version{Major: 1, Minor: 10}}, + {name: "surrounding whitespace", input: " 1.2.3 ", want: Version{Major: 1, Minor: 2, Patch: 3}}, + + // Pre-release and build metadata: GoReleaser can emit both, and a + // pre-release build must still be able to update. + {name: "prerelease rc", input: "1.2.3-rc.1", want: Version{Major: 1, Minor: 2, Patch: 3, Prerelease: "rc.1"}}, + {name: "prerelease without dot", input: "1.2.3-rc1", want: Version{Major: 1, Minor: 2, Patch: 3, Prerelease: "rc1"}}, + {name: "prerelease with hyphen", input: "1.2.3-alpha-beta", want: Version{Major: 1, Minor: 2, Patch: 3, Prerelease: "alpha-beta"}}, + {name: "prerelease with v prefix", input: "v0.8.0-next.2", want: Version{Minor: 8, Prerelease: "next.2"}}, + {name: "build metadata", input: "1.2.3+build.5", want: Version{Major: 1, Minor: 2, Patch: 3, Build: "build.5"}}, + {name: "build metadata with leading zero allowed", input: "1.2.3+001", want: Version{Major: 1, Minor: 2, Patch: 3, Build: "001"}}, + {name: "prerelease and build", input: "1.2.3-rc.1+sha.abc123", want: Version{Major: 1, Minor: 2, Patch: 3, Prerelease: "rc.1", Build: "sha.abc123"}}, + {name: "build metadata containing hyphen", input: "1.2.3+build-5", want: Version{Major: 1, Minor: 2, Patch: 3, Build: "build-5"}}, + {name: "empty", input: "", wantErr: true}, {name: "too few parts", input: "1.2", wantErr: true}, {name: "too many parts", input: "1.2.3.4", wantErr: true}, {name: "non numeric", input: "1.x.3", wantErr: true}, {name: "negative", input: "1.-2.3", wantErr: true}, - {name: "prerelease unsupported", input: "1.2.3-rc1", wantErr: true}, {name: "just v", input: "v", wantErr: true}, + {name: "leading zero in core", input: "01.2.3", wantErr: true}, + {name: "leading zero in patch", input: "1.2.03", wantErr: true}, + {name: "empty prerelease", input: "1.2.3-", wantErr: true}, + {name: "empty prerelease identifier", input: "1.2.3-rc..1", wantErr: true}, + {name: "leading zero in numeric prerelease", input: "1.2.3-01", wantErr: true}, + {name: "illegal prerelease character", input: "1.2.3-rc_1", wantErr: true}, + {name: "empty build metadata", input: "1.2.3+", wantErr: true}, + {name: "illegal build character", input: "1.2.3+build_5", wantErr: true}, + {name: "plus signed core", input: "1.+2.3", wantErr: true}, } for _, tt := range tests { @@ -41,10 +67,47 @@ func TestParseVersion(t *testing.T) { } } +func TestParseVersionRejectsOverflow(t *testing.T) { + // A component larger than int64 must be rejected, not silently wrapped. + huge := strconv.FormatUint(math.MaxUint64, 10) + "0" + if v, err := ParseVersion(huge + ".0.0"); err == nil { + t.Errorf("expected overflow error, got %+v", v) + } +} + func TestVersionString(t *testing.T) { - v := Version{Major: 1, Minor: 10, Patch: 2} - if got := v.String(); got != "1.10.2" { - t.Errorf("String() = %q, want %q", got, "1.10.2") + tests := []struct { + name string + v Version + want string + }{ + {name: "release", v: Version{Major: 1, Minor: 10, Patch: 2}, want: "1.10.2"}, + {name: "prerelease", v: Version{Major: 1, Prerelease: "rc.1"}, want: "1.0.0-rc.1"}, + {name: "build", v: Version{Major: 1, Build: "sha.abc"}, want: "1.0.0+sha.abc"}, + {name: "both", v: Version{Major: 1, Prerelease: "rc.1", Build: "sha.abc"}, want: "1.0.0-rc.1+sha.abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.v.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestVersionStringRoundTrips(t *testing.T) { + inputs := []string{"1.2.3", "1.2.3-rc.1", "1.2.3+build.5", "1.2.3-rc.1+build.5", "0.0.0"} + for _, in := range inputs { + t.Run(in, func(t *testing.T) { + v, err := ParseVersion(in) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got := v.String(); got != in { + t.Errorf("round trip: %q -> %q", in, got) + } + }) } } @@ -61,6 +124,20 @@ func TestCompareVersions(t *testing.T) { {name: "equal", a: "1.0.0", b: "1.0.0", want: 0}, {name: "major greater", a: "2.0.0", b: "1.99.99", want: 1}, {name: "major less", a: "0.9.0", b: "1.0.0", want: -1}, + + // SemVer 2.0.0 precedence rules. + {name: "prerelease sorts before release", a: "1.0.0-rc.1", b: "1.0.0", want: -1}, + {name: "release sorts after prerelease", a: "1.0.0", b: "1.0.0-rc.1", want: 1}, + {name: "numeric prerelease compares numerically", a: "1.0.0-rc.2", b: "1.0.0-rc.10", want: -1}, + {name: "numeric identifier below alphanumeric", a: "1.0.0-1", b: "1.0.0-alpha", want: -1}, + {name: "alpha before beta", a: "1.0.0-alpha", b: "1.0.0-beta", want: -1}, + {name: "larger identifier set wins", a: "1.0.0-alpha", b: "1.0.0-alpha.1", want: -1}, + {name: "equal prereleases", a: "1.0.0-rc.1", b: "1.0.0-rc.1", want: 0}, + {name: "build metadata ignored", a: "1.0.0+build.1", b: "1.0.0+build.9", want: 0}, + {name: "build metadata ignored against bare", a: "1.0.0+build.1", b: "1.0.0", want: 0}, + {name: "prerelease upgrade to release of same core", a: "0.8.0-rc.1", b: "0.8.0", want: -1}, + {name: "prerelease of newer core beats release", a: "0.9.0-rc.1", b: "0.8.0", want: 1}, + {name: "invalid a", a: "nope", b: "1.0.0", wantErr: true}, {name: "invalid b", a: "1.0.0", b: "nope", wantErr: true}, } From d4ffa887a84b5e69145f2120e219145290b3c8cb Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 18:29:19 +0200 Subject: [PATCH 3/4] test: guard the mutable maxDownloadBytes cap against parallel tests maxDownloadBytes is package-global mutable state that only tests write to. Nothing races today because no test in the package calls t.Parallel(), but that is a property that stops holding silently the moment someone adds one - the same shape as the internal/ui race fixed in #52. Document on the var that it is test-mutable only, that mutating tests must not call t.Parallel(), and that the withMaxDownloadBytes(t, n) helper is the only sanctioned way to change it. The helper restores the previous value via t.Cleanup, so an early t.Fatal cannot leak the shrunk cap into another test. No behaviour change. --- CLAUDE.md | 2 +- internal/selfupdate/selfupdate.go | 8 +++++++- internal/selfupdate/selfupdate_test.go | 13 ++++++++----- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0fafc82..927925c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ Custom `SCAAccessService` follows SDK conventions: - Version compare: in-house SemVer 2.0.0 parser (`ParseVersion`/`CompareVersions`). Handles pre-release and build metadata (GoReleaser can emit both) with SemVer precedence: build metadata ignored for ordering, pre-release sorts before its release. A leading `v`/`V` is tolerated; leading zeroes are rejected - Asset selection: `grant-cli___.tar.gz` (`.zip` on windows) — must stay in sync with `.goreleaser.yaml` - Integrity: SHA-256 of the archive checked against the release's `checksums.txt` (GNU `*filename` binary marker tolerated). **Trust model:** `checksums.txt` comes from the same origin as the archive, so it defends against corrupted/tampered downloads in transit, **not** against a compromised GitHub account or release pipeline. Signature verification would be needed for that. Note the checksum covers the *archive*, not the extracted binary — hence the independent size checks below - - Extraction: `archive/tar`+`compress/gzip` / `archive/zip`. Rejects absolute, drive-absolute (`C:\`), UNC and `..` paths (gosec G305); accepts only a single `grant`/`grant.exe` at the archive root (nested entries and duplicate candidates are errors). Size cap is 128 MiB (`maxDownloadBytes`), enforced by `readCapped`, which probes one byte past the cap — a bare `io.LimitReader` reports a *successful* short read and would silently install a truncated binary (gosec G110) + - Extraction: `archive/tar`+`compress/gzip` / `archive/zip`. Rejects absolute, drive-absolute (`C:\`), UNC and `..` paths (gosec G305); accepts only a single `grant`/`grant.exe` at the archive root (nested entries and duplicate candidates are errors). Size cap is 128 MiB (`maxDownloadBytes`), enforced by `readCapped`, which probes one byte past the cap — a bare `io.LimitReader` reports a *successful* short read and would silently install a truncated binary (gosec G110). `maxDownloadBytes` is a var only so tests can shrink it — mutate it exclusively through the `withMaxDownloadBytes(t, n)` helper (restores via `t.Cleanup`), and never call `t.Parallel()` in a test that does - Apply: `github.com/minio/selfupdate` v0.6.0 owns the staged-file write, the two-rename swap including the Windows path, and rollback — do not hand-roll this. grant adds the `fsync` of the staged file (minio does not sync) plus a best-effort directory sync. Seams: `applyWithOptions`, `prepareFn`, `commitFn` in `internal/selfupdate/apply.go` - **Atomicity, precisely:** each rename is atomic, so the installed binary is never partially written. The *pair* is not: a kill between the two renames, or a failed second rename whose rollback also fails, leaves the binary path absent with `.grant.old`/`.grant.new` beside it. `InterruptedUpdate()` detects that state and `recoveryHint()` prints the `mv` command that fixes it. Do not describe this as fully atomic - `selfUpdater` in `cmd/interfaces.go` is defined over grant-owned types: `UpdateSelf(ctx, current string) (newVersion string, updated bool, err error)` diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index 1c6aaf9..fd1e94f 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -44,7 +44,13 @@ const ( ) // maxDownloadBytes caps every download and every decompressed entry, guarding -// against decompression bombs (gosec G110). A variable so tests can shrink it. +// against decompression bombs (gosec G110). +// +// It is a var ONLY so tests can shrink it; nothing in production code may +// assign to it. Because it is package-global mutable state, any test that +// changes it MUST NOT call t.Parallel(), and MUST go through +// withMaxDownloadBytes so the value is restored via t.Cleanup even on an early +// t.Fatal. var maxDownloadBytes int64 = 128 << 20 // 128 MiB // httpDoer allows injecting a stub transport in tests. diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index a49586c..a3772ee 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -565,8 +565,11 @@ func TestUpdateSelf(t *testing.T) { }) } -// withSmallCap shrinks the download/decompression cap for a test. -func withSmallCap(t *testing.T, limit int64) { +// withMaxDownloadBytes shrinks the download/decompression cap for one test and +// restores it via t.Cleanup, so an early t.Fatal cannot leak the change into +// another test. maxDownloadBytes is package-global mutable state: callers of +// this helper MUST NOT call t.Parallel(). +func withMaxDownloadBytes(t *testing.T, limit int64) { t.Helper() orig := maxDownloadBytes maxDownloadBytes = limit @@ -613,7 +616,7 @@ func TestReadCappedDetectsOversize(t *testing.T) { // checksum-valid archive whose *decompressed* binary exceeds the cap must be // rejected, not silently truncated and installed. func TestExtractBinaryRejectsOversizedEntry(t *testing.T) { - withSmallCap(t, 64) + withMaxDownloadBytes(t, 64) big := strings.Repeat("A", 300) t.Run("tar.gz", func(t *testing.T) { @@ -680,7 +683,7 @@ func TestExtractBinaryRejectsTruncatedEntry(t *testing.T) { // TestDownloadRejectsOversizeBody pins the same boundary on the HTTP path. func TestDownloadRejectsOversizeBody(t *testing.T) { - withSmallCap(t, 32) + withMaxDownloadBytes(t, 32) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, strings.Repeat("x", 4096)) @@ -696,7 +699,7 @@ func TestDownloadRejectsOversizeBody(t *testing.T) { // TestUpdateSelfRejectsOversizedBinary is the end-to-end guard: the archive // checksum is valid, but the binary inside is too big, so nothing is applied. func TestUpdateSelfRejectsOversizedBinary(t *testing.T) { - withSmallCap(t, 128) + withMaxDownloadBytes(t, 128) archiveName := "grant-cli_0.7.0_linux_amd64.tar.gz" archive := buildTarGz(t, [][2]string{{"grant", strings.Repeat("A", 4096)}}) From 7e19ef49c9deafaabab15452951d81c6db37a63c Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 21:50:32 +0200 Subject: [PATCH 4/4] fix(selfupdate): send a User-Agent on every GitHub request GitHub's REST API rejects requests without a User-Agent (403 "Must provide a User-Agent header"), so grant update failed outright. Both the release lookup and the asset downloads now send grant-cli/, wired from cmd.version through selfupdate.New. Also hoist the per-entry size cap above the tar entry-type filter so skipped entries are bounded too. --- cmd/update.go | 2 +- internal/selfupdate/selfupdate.go | 24 ++++++-- internal/selfupdate/selfupdate_test.go | 80 ++++++++++++++++++++++---- 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/cmd/update.go b/cmd/update.go index 1b191dc..6bc4ec9 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -18,7 +18,7 @@ var updateTimeout = 5 * time.Minute // NewUpdateCommand creates the update command with production dependencies func NewUpdateCommand() *cobra.Command { - return NewUpdateCommandWithDeps(selfupdate.New(updateSlug)) + return NewUpdateCommandWithDeps(selfupdate.New(updateSlug, version)) } // NewUpdateCommandWithDeps creates the update command with injected dependencies diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index fd1e94f..e497db6 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -87,18 +87,28 @@ type Updater struct { client httpDoer goos string goarch string + // userAgent is sent on every request; GitHub's API rejects requests + // without one ("Must provide a User-Agent header", 403). + userAgent string // applyFn replaces the running executable; injectable for tests. applyFn func(newBinary []byte) error } -// New creates an Updater for the given "owner/repo" slug. -func New(slug string) *Updater { +// New creates an Updater for the given "owner/repo" slug. version is the +// running build's version, used only for the User-Agent; an empty value +// becomes "dev". +func New(slug, version string) *Updater { + v := strings.TrimPrefix(version, "v") + if v == "" { + v = "dev" + } return &Updater{ slug: slug, apiBaseURL: defaultAPIBaseURL, client: &http.Client{Timeout: downloadTimeout}, goos: runtime.GOOS, goarch: runtime.GOARCH, + userAgent: projectName + "/" + v, applyFn: applyBinary, } } @@ -171,6 +181,7 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (*ghRelease, error) { return nil, fmt.Errorf("failed to build release request: %w", err) } req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", u.userAgent) resp, err := u.client.Do(req) if err != nil { @@ -203,6 +214,7 @@ func (u *Updater) download(ctx context.Context, url string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to build download request: %w", err) } + req.Header.Set("User-Agent", u.userAgent) resp, err := u.client.Do(req) if err != nil { @@ -372,15 +384,17 @@ func extractFromTarGz(archive []byte) ([]byte, error) { if err := checkArchivePath(hdr.Name); err != nil { return nil, err } + // Checked for every entry, not just the binary: skipped entries are + // still decompressed by the next Next() call. + if hdr.Size > maxDownloadBytes { + return nil, fmt.Errorf("%s in archive declares %d bytes, over the %d byte limit", hdr.Name, hdr.Size, maxDownloadBytes) + } if hdr.Typeflag != tar.TypeReg || !isBinaryEntry(hdr.Name) { continue } if found != nil { return nil, fmt.Errorf("archive contains more than one %s binary", binaryName) } - if hdr.Size > maxDownloadBytes { - return nil, fmt.Errorf("%s in archive declares %d bytes, over the %d byte limit", hdr.Name, hdr.Size, maxDownloadBytes) - } data, err := readCapped(tr, maxDownloadBytes, hdr.Name) // gosec G110 if err != nil { return nil, fmt.Errorf("failed to read %s from archive: %w", hdr.Name, err) diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index a3772ee..7e675d6 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -98,7 +98,7 @@ func TestFetchLatestRelease(t *testing.T) { })) defer srv.Close() - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") u.apiBaseURL = srv.URL rel, err := u.fetchLatestRelease(t.Context()) @@ -465,7 +465,7 @@ func TestUpdateSelf(t *testing.T) { t.Run("already up to date", func(t *testing.T) { srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") u.apiBaseURL = srv.URL u.goos, u.goarch = "linux", "amd64" @@ -489,7 +489,7 @@ func TestUpdateSelf(t *testing.T) { t.Run("newer version applied", func(t *testing.T) { srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") u.apiBaseURL = srv.URL u.goos, u.goarch = "linux", "amd64" @@ -514,7 +514,7 @@ func TestUpdateSelf(t *testing.T) { t.Run("checksum mismatch aborts before apply", func(t *testing.T) { bad := checksumsFor(archiveName, []byte("different bytes entirely")) srv := newFixtureServer(t, archiveName, archive, bad) - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") u.apiBaseURL = srv.URL u.goos, u.goarch = "linux", "amd64" @@ -531,7 +531,7 @@ func TestUpdateSelf(t *testing.T) { t.Run("no asset for platform", func(t *testing.T) { srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") u.apiBaseURL = srv.URL u.goos, u.goarch = "plan9", "386" u.applyFn = func(b []byte) error { return nil } @@ -543,7 +543,7 @@ func TestUpdateSelf(t *testing.T) { t.Run("invalid current version", func(t *testing.T) { srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") u.apiBaseURL = srv.URL u.applyFn = func(b []byte) error { return nil } @@ -554,7 +554,7 @@ func TestUpdateSelf(t *testing.T) { t.Run("apply error propagated", func(t *testing.T) { srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") u.apiBaseURL = srv.URL u.goos, u.goarch = "linux", "amd64" u.applyFn = func(b []byte) error { return errTestApply } @@ -690,7 +690,7 @@ func TestDownloadRejectsOversizeBody(t *testing.T) { })) defer srv.Close() - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") if _, err := u.download(t.Context(), srv.URL); err == nil { t.Fatal("expected an error for an oversized download, got nil") } @@ -705,7 +705,7 @@ func TestUpdateSelfRejectsOversizedBinary(t *testing.T) { archive := buildTarGz(t, [][2]string{{"grant", strings.Repeat("A", 4096)}}) srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") u.apiBaseURL = srv.URL u.goos, u.goarch = "linux", "amd64" @@ -727,7 +727,7 @@ func TestUpdateSelfPrereleaseCanUpdate(t *testing.T) { archive := buildTarGz(t, [][2]string{{"grant", "new binary"}}) srv := newFixtureServer(t, archiveName, archive, checksumsFor(archiveName, archive)) - u := New("aaearon/grant-cli") + u := New("aaearon/grant-cli", "v0.6.1") u.apiBaseURL = srv.URL u.goos, u.goarch = "linux", "amd64" u.applyFn = func(b []byte) error { return nil } @@ -743,3 +743,63 @@ func TestUpdateSelfPrereleaseCanUpdate(t *testing.T) { t.Errorf("latest = %q, want 0.7.0", latest) } } + +// TestRequestsSendUserAgent guards against GitHub's 403 "Must provide a +// User-Agent header": every request the updater makes — the release lookup and +// both asset downloads — must carry one. +func TestRequestsSendUserAgent(t *testing.T) { + archiveName := "grant-cli_0.7.0_linux_amd64.tar.gz" + archive := buildTarGz(t, [][2]string{{"grant", "new binary"}}) + + seen := map[string]string{} + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + checksums := checksumsFor(archiveName, archive) + + mux.HandleFunc("/repos/aaearon/grant-cli/releases/latest", func(w http.ResponseWriter, r *http.Request) { + seen[r.URL.Path] = r.Header.Get("User-Agent") + fmt.Fprintf(w, `{"tag_name":"v0.7.0","assets":[ + {"name":"checksums.txt","browser_download_url":"%[1]s/download/checksums.txt"}, + {"name":%[2]q,"browser_download_url":"%[1]s/download/%[2]s"} + ]}`, srv.URL, archiveName) + }) + mux.HandleFunc("/download/checksums.txt", func(w http.ResponseWriter, r *http.Request) { + seen[r.URL.Path] = r.Header.Get("User-Agent") + w.Write(checksums) //nolint:errcheck // test server + }) + mux.HandleFunc("/download/"+archiveName, func(w http.ResponseWriter, r *http.Request) { + seen[r.URL.Path] = r.Header.Get("User-Agent") + w.Write(archive) //nolint:errcheck // test server + }) + + u := New("aaearon/grant-cli", "v0.6.1") + u.apiBaseURL = srv.URL + u.goos, u.goarch = "linux", "amd64" + u.applyFn = func(b []byte) error { return nil } + + if _, _, err := u.UpdateSelf(t.Context(), "0.6.1"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wantPaths := []string{ + "/repos/aaearon/grant-cli/releases/latest", + "/download/checksums.txt", + "/download/" + archiveName, + } + for _, p := range wantPaths { + got, ok := seen[p] + if !ok { + t.Fatalf("%s was never requested", p) + } + if got != "grant-cli/0.6.1" { + t.Errorf("User-Agent for %s = %q, want %q", p, got, "grant-cli/0.6.1") + } + } +} + +func TestNewUserAgentFallsBackToDev(t *testing.T) { + if got := New("aaearon/grant-cli", "").userAgent; got != "grant-cli/dev" { + t.Errorf("userAgent = %q, want %q", got, "grant-cli/dev") + } +}