From 4df2e480f0ae7f9bcc794e4e7f606f6974d29182 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 22:33:30 -0700 Subject: [PATCH] Make basecamp upgrade a real upgrade: native, verified self-update basecamp upgrade previously printed a release URL with ok:true for every install method it couldn't handle, and reported brew/scoop success on child exit code alone. Now: - Installer-script/tarball installs under the user's home self-update: download the platform asset, verify authenticity via sigstore-go (identity pinned to the release workflow at the version tag, SCT + tlog + observer thresholds, artifact-bound) and integrity via sha256, swap the executable transactionally (target path continuously occupied on unix; rename shuffle with rollback on Windows), and confirm the installed binary reports the new version. Backup restored on post-verify failure; sidecars reaped on every CLI invocation. - Fail-closed path policy: root, /nix/store, outside-home (Rel-based, no prefix matching), unwritable dir, and go-install provenance (ldflags vs debug.ReadBuildInfo) all refuse with method-specific hints. - Exit contract: exit 0 only for up_to_date/dev or applied-and- confirmed upgrades. upgrade_required / upgrade_incomplete / upgrade_unverified / upgrade_failed are ok:false and nonzero; legacy cask/manifest migration is now an error, not a success. - Brew/scoop delegation post-verifies via manager-derived paths (brew --prefix, scoop prefix), never os.Executable. - Installer cosign capability gate: v3 bare, v2.6+ needs --new-bundle-format=true, older warns and skips (checksums still verified). goreleaser pins the published bundle format explicitly. - Real published v0.8.1 checksums + protobuf bundle + TUF trusted root vendored as hermetic verification fixtures. - New 3-OS upgrade-smoke workflow exercises the full flow against the real latest release, plus install.sh/ps1 cosign tier legs. --- .github/workflows/upgrade-smoke.yml | 212 +++ .goreleaser.yaml | 4 + README.md | 23 + RELEASING.md | 10 + e2e/installer.bats | 221 +++ go.mod | 74 +- go.sum | 346 ++++- install.md | 4 + internal/cli/root.go | 4 + internal/commands/doctor.go | 78 +- internal/commands/doctor_test.go | 30 + .../testdata/selfupdate/checksums.txt | 27 + .../testdata/selfupdate/checksums.txt.bundle | 1 + .../testdata/selfupdate/trusted_root.json | 126 ++ internal/commands/upgrade.go | 191 ++- internal/commands/upgrade_selfupdate.go | 791 ++++++++++ internal/commands/upgrade_selfupdate_test.go | 1329 +++++++++++++++++ internal/commands/upgrade_test.go | 63 +- internal/version/version.go | 12 + internal/version/version_test.go | 17 + nix/package.nix | 2 +- scripts/install.ps1 | 44 +- scripts/install.sh | 70 +- 23 files changed, 3583 insertions(+), 96 deletions(-) create mode 100644 .github/workflows/upgrade-smoke.yml create mode 100644 internal/commands/testdata/selfupdate/checksums.txt create mode 100644 internal/commands/testdata/selfupdate/checksums.txt.bundle create mode 100644 internal/commands/testdata/selfupdate/trusted_root.json create mode 100644 internal/commands/upgrade_selfupdate.go create mode 100644 internal/commands/upgrade_selfupdate_test.go diff --git a/.github/workflows/upgrade-smoke.yml b/.github/workflows/upgrade-smoke.yml new file mode 100644 index 000000000..292eae54f --- /dev/null +++ b/.github/workflows/upgrade-smoke.yml @@ -0,0 +1,212 @@ +--- +# Upgrade smoke: end-to-end proof that `basecamp upgrade` self-updates a +# stale binary against the REAL latest release — real download, real Sigstore +# verification, and (on Windows) the real locked-exe rename shuffle — plus +# installer legs pinning the cosign version→flag capability gate against the +# published bundle format. +name: Upgrade Smoke + +on: + push: + branches: [main] + paths: + - "internal/commands/upgrade*" + - "internal/commands/doctor.go" + - "internal/version/**" + - "go.mod" + - "go.sum" + - ".goreleaser.yaml" + - "scripts/install.sh" + - "scripts/install.ps1" + - ".github/workflows/upgrade-smoke.yml" + pull_request: + paths: + - "internal/commands/upgrade*" + - "internal/commands/doctor.go" + - "internal/version/**" + - "go.mod" + - "go.sum" + - ".goreleaser.yaml" + - "scripts/install.sh" + - "scripts/install.ps1" + - ".github/workflows/upgrade-smoke.yml" + +permissions: {} + +concurrency: + group: upgrade-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + native-upgrade: + name: native self-update (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: read + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Build a stale 0.0.1 binary under HOME + run: | + ext="" + [[ "$RUNNER_OS" == "Windows" ]] && ext=".exe" + mkdir -p "$HOME/bin" + go build \ + -ldflags "-X github.com/basecamp/basecamp-cli/internal/version.Version=0.0.1" \ + -o "$HOME/bin/basecamp$ext" ./cmd/basecamp + "$HOME/bin/basecamp$ext" --version + + - name: Run basecamp upgrade against the real latest release + env: + BASECAMP_NO_KEYRING: "1" + # Anonymous api.github.com requests share the runner egress IP's + # rate limit; the CLI attaches this token to its releases/latest + # lookup (attachGitHubAuth) so the check can't 403. + GITHUB_TOKEN: ${{ github.token }} + run: | + ext="" + [[ "$RUNNER_OS" == "Windows" ]] && ext=".exe" + "$HOME/bin/basecamp$ext" upgrade + + - name: Assert the installed binary reports the latest release + env: + GH_TOKEN: ${{ github.token }} + run: | + ext="" + [[ "$RUNNER_OS" == "Windows" ]] && ext=".exe" + latest=$(gh api repos/basecamp/basecamp-cli/releases/latest --jq .tag_name) + latest="${latest#v}" + got=$("$HOME/bin/basecamp$ext" --version | awk '{print $NF}') + echo "latest=$latest installed=$got" + test "$got" = "$latest" + + installer-sh: + name: install.sh cosign ${{ matrix.cosign }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # sha256 values come from each cosign release's cosign_checksums.txt. + include: + - cosign: v3.0.5 # new bundle format is the default: bare verify-blob + sha256: db15cc99e6e4837daabab023742aaddc3841ce57f193d11b7c3e06c8003642b2 + expect: verified + - cosign: v2.6.0 # floor for --new-bundle-format=true + sha256: ea5c65f99425d6cfbb5c4b5de5dac035f14d09131c1a0ea7c7fc32eab39364f9 + expect: verified + - cosign: v2.4.0 # unsupported: warn + skip, install still succeeds + sha256: cd7636b3586a3bdac2d9c8f3b421ed119edcb20499107887fd929211110e8418 + expect: skipped + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install cosign ${{ matrix.cosign }} (digest-pinned) + run: | + curl -fsSL -o /tmp/cosign \ + "https://github.com/sigstore/cosign/releases/download/${{ matrix.cosign }}/cosign-linux-amd64" + echo "${{ matrix.sha256 }} /tmp/cosign" | sha256sum -c - + chmod +x /tmp/cosign + sudo mv /tmp/cosign /usr/local/bin/cosign + cosign version + + - name: Run install.sh against the latest release + env: + BASECAMP_BIN_DIR: /home/runner/.local/bin + BASECAMP_SKIP_SETUP: "1" + BASECAMP_SETUP_AGENT: none + BASECAMP_NO_KEYRING: "1" + run: | + set -o pipefail + bash scripts/install.sh | tee install.log + + - name: Assert the signature verification path + run: | + if [[ "${{ matrix.expect }}" == "verified" ]]; then + grep -q "Signature verified" install.log + else + grep -q "Skipping signature verification" install.log + if grep -q "Signature verified" install.log; then + echo "must not claim the signature was verified" >&2 + exit 1 + fi + fi + grep -q "Checksum verified" install.log + "$HOME/.local/bin/basecamp" --version + + installer-ps1: + name: install.ps1 cosign ${{ matrix.cosign }} + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + # sha256 values come from each cosign release's cosign_checksums.txt. + include: + - cosign: v3.0.5 + sha256: 44e9e44202b67ddfaaf5ea1234f5a265417960c4ae98c5b57c35bc40ba9dd714 + expect: verified + - cosign: v2.6.0 + sha256: 7beb4dd1e19a72c328bbf7c0d7342d744edbf5cbb082f227b2b76e04a21c16ef + expect: verified + - cosign: v2.4.0 + sha256: 88f1addbae6bdd83ec2c067470c1f56b6d0d3ba35f49ad34603f2502cb2933f3 + expect: skipped + permissions: + contents: read + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install cosign ${{ matrix.cosign }} (digest-pinned) + run: | + $binDir = Join-Path $env:RUNNER_TEMP 'cosign-bin' + New-Item -ItemType Directory -Force -Path $binDir | Out-Null + $exe = Join-Path $binDir 'cosign.exe' + Invoke-WebRequest -UseBasicParsing ` + -Uri "https://github.com/sigstore/cosign/releases/download/${{ matrix.cosign }}/cosign-windows-amd64.exe" ` + -OutFile $exe + $actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLowerInvariant() + if ($actual -ne '${{ matrix.sha256 }}') { throw "cosign digest mismatch: $actual" } + Add-Content $env:GITHUB_PATH $binDir + + - name: Run install.ps1 against the latest release + env: + BASECAMP_SKIP_SETUP: "1" + BASECAMP_SETUP_AGENT: none + BASECAMP_NO_KEYRING: "1" + run: | + cosign version + & .\scripts\install.ps1 *>&1 | Tee-Object -FilePath install.log + + - name: Assert the signature verification path + run: | + $log = Get-Content install.log -Raw + if ('${{ matrix.expect }}' -eq 'verified') { + if ($log -notmatch 'Signature verified') { throw 'expected cosign signature verification' } + } else { + if ($log -notmatch "Skipping signature verification") { throw 'expected the skip warning' } + if ($log -match 'Signature verified') { throw 'must not claim the signature was verified' } + } + if ($log -notmatch 'Checksum verified') { throw 'expected checksum verification' } + & "$env:USERPROFILE\bin\basecamp.exe" --version diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7b7307b0f..389381513 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -109,6 +109,10 @@ signs: args: - sign-blob - '--bundle=${signature}' + # Pin the published bundle format explicitly (protobuf, v0.3+json) so it + # no longer depends on the CI cosign default. Installers and `basecamp + # upgrade` verify this format; cosign v3 defaults to it anyway. + - '--new-bundle-format=true' - '${artifact}' - '--yes' artifacts: checksum diff --git a/README.md b/README.md index f6768afdb..b9405d160 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,29 @@ go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest +## Upgrading + +```bash +basecamp upgrade +``` + +What happens depends on how the CLI was installed: + +- **Installer script / tarball** (a binary under your home directory, e.g. `~/bin` or `~/.local/bin`): upgrades in place. The CLI downloads the release for your platform, verifies its Sigstore signature (the keyless `checksums.txt.bundle` published by the release pipeline, identity-pinned to the release workflow and tag) and SHA-256 checksum, swaps the executable transactionally, and confirms the installed binary reports the new version. On failure the previous binary is restored; in the worst case — restoration itself fails mid-swap — the error names the preserved backup file next to the binary so you can put it back by hand. +- **Homebrew / Scoop**: delegates to `brew upgrade --cask` / `scoop update`, then verifies the manager-installed binary actually reports the new version. +- **System packages** (apt/dnf/apk, AUR, Nix) and **`go install` builds**: never touched. `basecamp upgrade` exits nonzero with upgrade guidance for that install method (the exact command where it can be known, e.g. `go install`; otherwise which package manager to use). + +`basecamp upgrade` exits 0 only when there is no update, or the update was applied *and confirmed*. Every other outcome is a structured failure (`"ok": false` in JSON) with one of these codes: + +| Code | Meaning | +|---|---| +| `upgrade_required` | An update exists but the CLI won't apply it for this install method — the hint carries the right next step | +| `upgrade_incomplete` | The package manager exited 0 but the binary still reports the old version | +| `upgrade_unverified` | The upgrade may have worked, but the installed version could not be confirmed | +| `upgrade_failed` | The update check, download, signature/checksum verification, or executable swap failed — the previous binary remains installed (or the error names the preserved backup if restoration also failed) | + +The install scripts verify release signatures when `cosign` is available: cosign v3 verifies the published bundle format as-is, v2.6+ is driven with `--new-bundle-format=true`, and older versions skip signature verification with a warning (SHA-256 checksums are always verified). + ## Usage ```bash diff --git a/RELEASING.md b/RELEASING.md index a329184fe..0607d1e6d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -76,6 +76,16 @@ basecamp skill install - No `replace` directives in go.mod - `make release-check` passes (includes check, replace-check, vuln scan, race-test, surface compat) +## Release size budget + +Stripped release binary ≤ 38 MiB; gzip-compressed binary ≤ 13 MiB (a proxy +for release-archive size), per platform. Baseline set when in-process +Sigstore/TUF verification landed for `basecamp upgrade` (v0.8.1 measured +24.4 MiB stripped / 8.0 MiB gzipped; the sigstore-go tree added ~9.7 MiB +stripped / ~3.2 MiB gzipped — accepted cost of verifying releases without a +cosign dependency). An increase beyond either bound needs explicit review of +what grew, not a budget bump. Enforcement is manual for now. + ## CI secrets **Repository secrets** (Settings → Secrets and variables → Actions): diff --git a/e2e/installer.bats b/e2e/installer.bats index b7cc57189..8584dfa38 100644 --- a/e2e/installer.bats +++ b/e2e/installer.bats @@ -391,3 +391,224 @@ EOF [[ "$output" == *"branch2-ok"* ]] [[ "$output" == *"branch3-ok"* ]] } + +# Releases publish the new (protobuf) Sigstore bundle format. cosign v3 parses +# it by default, v2.6–v2.x needs --new-bundle-format=true, and older cosign +# cannot verify it at all (v2.4 chokes on the bundle's tlog key type, v2.2 +# lacks the flag) — those must warn and skip, preserving cosign's optional +# posture, never fail the install or false-green the verification. + +@test "cosign_bundle_support: v3 verifies with no extra flag" { + run bash -c "source '$INSTALL_SH'; cosign_bundle_support v3.0.2" + [[ "$status" -eq 0 ]] + [[ -z "$output" ]] +} + +@test "cosign_bundle_support: v2.6 verifies with --new-bundle-format=true" { + run bash -c "source '$INSTALL_SH'; cosign_bundle_support v2.6.0" + [[ "$status" -eq 0 ]] + [[ "$output" == "--new-bundle-format=true" ]] +} + +@test "cosign_bundle_support: v2.4 is unsupported" { + run bash -c "source '$INSTALL_SH'; cosign_bundle_support v2.4.0" + [[ "$status" -ne 0 ]] +} + +@test "cosign_bundle_support: garbage version is unsupported" { + run bash -c "source '$INSTALL_SH'; cosign_bundle_support 'devel-deadbeef'" + [[ "$status" -ne 0 ]] + run bash -c "source '$INSTALL_SH'; cosign_bundle_support ''" + [[ "$status" -ne 0 ]] +} + +# write_cosign_stub emits a cosign stub whose `cosign version` reports the +# given GitVersion and which logs every other invocation's argv. +write_cosign_stub() { + local ver="$1" + { + echo '#!/usr/bin/env bash' + echo 'if [[ "$1" == "version" ]]; then' + echo " echo 'GitVersion: ${ver}'" + echo ' exit 0' + echo 'fi' + echo "echo \"\$@\" >> \"$LOG\"" + echo 'exit 0' + } > "$STUB_DIR/cosign" + chmod +x "$STUB_DIR/cosign" +} + +# run_verify_checksums drives the real verify_checksums with a stubbed cosign +# and a no-network curl_run, against a locally-built archive + checksums.txt. +run_verify_checksums() { + run bash -c " + set -euo pipefail + export PATH=\"$STUB_DIR:\$PATH\" + source '$INSTALL_SH' + tmp_dir='$STUB_DIR/work' + mkdir -p \"\$tmp_dir\" + printf 'archive-bytes' > \"\$tmp_dir/basecamp_9.9.9_linux_amd64.tar.gz\" + sha=\$(cd \"\$tmp_dir\" && \$(find_sha256_cmd) basecamp_9.9.9_linux_amd64.tar.gz | awk '{print \$1}') + verify_checksums_stub_sums() { printf '%s basecamp_9.9.9_linux_amd64.tar.gz\n' \"\$sha\" > \"\$tmp_dir/checksums.txt\"; } + # No-network curl_run: serve the locally-computed checksums.txt and a + # placeholder bundle (cosign is stubbed, so its content is irrelevant). + curl_run() { + local arg dest='' + local prev='' + for arg in \"\$@\"; do + [[ \"\$prev\" == '-o' ]] && dest=\"\$arg\" + prev=\"\$arg\" + done + if [[ \"\$dest\" == *checksums.txt ]]; then + verify_checksums_stub_sums + elif [[ -n \"\$dest\" ]]; then + printf 'stub-bundle' > \"\$dest\" + fi + return 0 + } + verify_checksums 9.9.9 \"\$tmp_dir\" basecamp_9.9.9_linux_amd64.tar.gz + cat '$LOG' 2>/dev/null || true + " +} + +@test "verify_checksums with cosign v3 runs verify-blob without the compat flag" { + write_cosign_stub v3.0.2 + run_verify_checksums + [[ "$status" -eq 0 ]] + [[ "$output" == *"Signature verified"* ]] + [[ "$output" == *"verify-blob"* ]] + [[ "$output" != *"--new-bundle-format"* ]] +} + +@test "verify_checksums with cosign v2.6 adds --new-bundle-format=true" { + write_cosign_stub v2.6.0 + run_verify_checksums + [[ "$status" -eq 0 ]] + [[ "$output" == *"Signature verified"* ]] + [[ "$output" == *"--new-bundle-format=true"* ]] +} + +@test "verify_checksums with cosign v2.4 warns, skips verification, still succeeds" { + write_cosign_stub v2.4.0 + run_verify_checksums + [[ "$status" -eq 0 ]] + [[ "$output" == *"Checksum verified"* ]] + [[ "$output" == *"Skipping signature verification"* ]] + [[ "$output" != *"verify-blob"* ]] +} + +@test "verify_checksums with unparseable cosign version warns and skips" { + write_cosign_stub 'devel' + run_verify_checksums + [[ "$status" -eq 0 ]] + [[ "$output" == *"Skipping signature verification"* ]] + [[ "$output" != *"verify-blob"* ]] +} + +# A cosign whose `version` subcommand itself FAILS must not abort the +# installer: under `set -euo pipefail` the version probe runs inside a command +# substitution, and without the guard in cosign_version the nonzero exit +# propagates and kills the install (reproduced as exit 42). It must degrade to +# the warn-and-skip path with checksum verification intact. +@test "verify_checksums with broken cosign (version exits nonzero) warns, skips, still succeeds" { + { + echo '#!/usr/bin/env bash' + echo 'if [[ "$1" == "version" ]]; then' + echo ' echo "cosign: catastrophic startup failure" >&2' + echo ' exit 42' + echo 'fi' + echo "echo \"\$@\" >> \"$LOG\"" + echo 'exit 0' + } > "$STUB_DIR/cosign" + chmod +x "$STUB_DIR/cosign" + + run_verify_checksums + [[ "$status" -eq 0 ]] + [[ "$output" == *"Checksum verified"* ]] + [[ "$output" == *"Skipping signature verification"* ]] + [[ "$output" != *"verify-blob"* ]] +} + +# install.ps1 equivalent, executed (not review-only): extract the two cosign +# functions from the AST — Main never runs — shadow cosign/Download-File, and +# drive all three version tiers. +@test "install.ps1 cosign version gate selects bare, flagged, and skip paths" { + if ! command -v pwsh >/dev/null 2>&1; then + if [[ -n "${CI:-}" ]]; then + echo "pwsh is required in CI for install.ps1 cosign gate coverage" >&2 + return 1 + fi + skip "pwsh not installed" + fi + + cat > "$STUB_DIR/cosign-driver.ps1" <<'EOF' +$ErrorActionPreference = 'Stop' +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:INSTALL_PS1_PATH, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "install.ps1 parse errors: $($parseErrors -join '; ')" } +foreach ($name in @('Get-CosignBundleSupport', 'Verify-CosignSignature')) { + $fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name }, $true) + if (-not $fn) { throw "$name not found in install.ps1" } + . ([scriptblock]::Create($fn.Extent.Text)) +} + +function Step([string]$Message) { Write-Host "step: $Message" } +function Info([string]$Message) { Write-Host "info: $Message" } +function Fail([string]$Message) { throw $Message } +function Download-File([string]$Url, [string]$Destination) { Set-Content -Path $Destination -Value 'stub-bundle' } + +$script:CosignVersion = '' +$script:CosignArgs = $null +function cosign { + if ($args[0] -eq 'version') { + "GitVersion: $script:CosignVersion" + } else { + $script:CosignArgs = $args -join ' ' + } + $global:LASTEXITCODE = 0 +} + +$tmp = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName()) +New-Item -ItemType Directory -Path $tmp | Out-Null +Set-Content -Path (Join-Path $tmp 'checksums.txt') -Value 'stub' + +# v3: bare verify-blob, no compat flag. +$script:CosignVersion = 'v3.0.2'; $script:CosignArgs = $null +Verify-CosignSignature -Version '9.9.9' -BaseUrl 'https://example.invalid' -TmpDir $tmp +if (-not $script:CosignArgs) { throw 'v3: cosign verify-blob was not invoked' } +if ($script:CosignArgs -notmatch 'verify-blob') { throw "v3: unexpected args: $script:CosignArgs" } +if ($script:CosignArgs -match 'new-bundle-format') { throw "v3: compat flag must not be passed: $script:CosignArgs" } +'v3-ok' + +# v2.6: compat flag required. +$script:CosignVersion = 'v2.6.0'; $script:CosignArgs = $null +Verify-CosignSignature -Version '9.9.9' -BaseUrl 'https://example.invalid' -TmpDir $tmp +if ($script:CosignArgs -notmatch '--new-bundle-format=true') { throw "v2.6: compat flag missing: $script:CosignArgs" } +'v26-ok' + +# v2.4: warn + skip, verify-blob never runs. +$script:CosignVersion = 'v2.4.0'; $script:CosignArgs = $null +Verify-CosignSignature -Version '9.9.9' -BaseUrl 'https://example.invalid' -TmpDir $tmp +if ($null -ne $script:CosignArgs) { throw "v2.4: verify-blob must not run: $script:CosignArgs" } +'v24-ok' + +# Garbage version: warn + skip. +$script:CosignVersion = 'devel'; $script:CosignArgs = $null +Verify-CosignSignature -Version '9.9.9' -BaseUrl 'https://example.invalid' -TmpDir $tmp +if ($null -ne $script:CosignArgs) { throw "garbage: verify-blob must not run: $script:CosignArgs" } +'garbage-ok' + +Remove-Item -Recurse -Force $tmp +EOF + + run bash -c " + set -euo pipefail + export INSTALL_PS1_PATH='$INSTALL_PS1' + pwsh -NoProfile -File '$STUB_DIR/cosign-driver.ps1' + " + [[ "$status" -eq 0 ]] + [[ "$output" == *"v3-ok"* ]] + [[ "$output" == *"v26-ok"* ]] + [[ "$output" == *"v24-ok"* ]] + [[ "$output" == *"garbage-ok"* ]] +} diff --git a/go.mod b/go.mod index 8441769a8..091a10811 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/fsnotify/fsnotify v1.10.1 github.com/gofrs/flock v0.13.0 github.com/itchyny/gojq v0.12.19 + github.com/sigstore/sigstore-go v1.3.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -30,10 +31,14 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect @@ -45,17 +50,50 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/danieljoos/wincred v1.2.3 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.25.5 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/loads v0.25.0 // indirect + github.com/go-openapi/runtime v0.33.0 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/strfmt v0.27.0 // indirect + github.com/go-openapi/swag v0.26.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.0 // indirect + github.com/go-openapi/swag/conv v0.27.3 // indirect + github.com/go-openapi/swag/fileutils v0.27.3 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.27.3 // indirect + github.com/go-openapi/swag/loading v0.27.3 // indirect + github.com/go-openapi/swag/mangling v0.27.3 // indirect + github.com/go-openapi/swag/netutils v0.27.0 // indirect + github.com/go-openapi/swag/pools v0.27.3 // indirect + github.com/go-openapi/swag/stringutils v0.27.3 // indirect + github.com/go-openapi/swag/typeutils v0.27.3 // indirect + github.com/go-openapi/swag/yamlutils v0.27.3 // indirect + github.com/go-openapi/validate v0.26.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect + github.com/google/go-containerregistry v0.21.7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/in-toto/attestation v1.2.0 // indirect + github.com/in-toto/in-toto-golang v0.11.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -67,12 +105,36 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/oapi-codegen/runtime v1.6.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/protobuf-specs v0.5.1 // indirect + github.com/sigstore/rekor v1.5.3 // indirect + github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect + github.com/sigstore/sigstore v1.10.8 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect + github.com/transparency-dev/formats v0.1.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/net v0.56.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/term v0.44.0 // indirect + golang.org/x/term v0.45.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + k8s.io/klog/v2 v2.140.0 // indirect ) diff --git a/go.sum b/go.sum index b89a7152d..6e9033754 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,38 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= @@ -15,8 +47,40 @@ github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= @@ -27,9 +91,17 @@ github.com/basecamp/basecamp-sdk/go v0.12.0 h1:N+sdsz109J5PUu8AZM5UT7vS00Z76s+zG github.com/basecamp/basecamp-sdk/go v0.12.0/go.mod h1:r83ralDQ0q9vbAby5qQ5x9hgCgUdJLDLHYpiU6jaFjE= github.com/basecamp/cli v0.2.2-0.20260728023309-04e401b12c6c h1:+5sQBl8sqYoD1Qhwsibn8sBCKWPyZ9NDez6mnuo9Afo= github.com/basecamp/cli v0.2.2-0.20260728023309-04e401b12c6c/go.mod h1:EK1Dba6DEw8ZAilVBpf/jri3ONDV7LQkLACSDe73f/c= +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/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -70,44 +142,177 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= +github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= +github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= +github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU= +github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= +github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= +github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= +github.com/go-openapi/swag/cmdutils v0.27.0 h1:aIKiqhB29AaP+7xm8/CPg3uOpeHx2SUp6TvMpu/a31Y= +github.com/go-openapi/swag/cmdutils v0.27.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU= +github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ= +github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE= +github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc= +github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0= +github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE= +github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg= +github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.0 h1:lEUG+hHvPvLggB3A8snFk0IRKNf9uC0YKc+7WYqvAF8= +github.com/go-openapi/swag/netutils v0.27.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk= +github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI= +github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4= +github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs= +github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= +github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +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-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= +github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= 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/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= +github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= +github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -119,8 +324,12 @@ github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/a github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -129,12 +338,24 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -142,6 +363,38 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= +github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= +github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= +github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.3.0 h1:hnIMHREyCNTYFtOE1o7ae3Axa9B5W5EjUSBJICP2NBE= +github.com/sigstore/sigstore-go v1.3.0/go.mod h1:AyRQXfpH89py1twjE3kEZxlRersng90GSYqQV9zGJE8= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.1.3 h1:Fc+LjCTfik1lh3YLkaosENfkXa3R2Y1nswiUKutBdFA= +github.com/sigstore/timestamp-authority/v2 v2.1.3/go.mod h1:myoFOKJB/u5vNTFwvBBJVkG3NnOBeIJevbfjNeasLjo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -154,33 +407,106 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0 h1:3s6YMgMOBZRU8qG6ybpKSF2Sau+y3sMvxR911M59SwA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0/go.mod h1:X8UNvbQu2wanAGa8ixRUU/DWt1V2hUBfvPGy6s9nE2s= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= +github.com/tink-crypto/tink-go/v2 v2.7.0 h1:k7QnUXJ1cRDpvoy/5l1FimZqMAArRff8vjUqzi5N04o= +github.com/tink-crypto/tink-go/v2 v2.7.0/go.mod h1:cWNpQ/yAT/QHzAV0kBGMOSJzzYTKofDZdJaUqOPPWCI= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= +go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= +google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/install.md b/install.md index e100cb9f8..a8f409458 100644 --- a/install.md +++ b/install.md @@ -94,6 +94,10 @@ export PATH="$HOME/bin:$PATH" # Git Bash / Windows bash environments export PATH="$HOME/go/bin:$PATH" ``` +### Upgrading later + +Run `basecamp upgrade`. Installer-script installs upgrade in place (Sigstore-verified download, transactional executable swap, post-upgrade version confirmation). Homebrew and Scoop installs delegate to their package manager, then verify the installed binary reports the new version. System packages (apt/dnf/apk, AUR, Nix) and `go install` builds are never touched — the command exits nonzero with upgrade guidance for that install method instead. Treat any nonzero exit as **not upgraded** and follow the hint in the error (`upgrade_required`, `upgrade_incomplete`, `upgrade_unverified`, or `upgrade_failed`). + --- ## Step 2: Authenticate diff --git a/internal/cli/root.go b/internal/cli/root.go index 6f974f02d..d92e449b2 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -62,6 +62,10 @@ func NewRootCmd() *cobra.Command { hardenConfigDir(cfgDir) } + // Reap leftover self-update sidecars (crashed upgrade, or a + // Windows .old still locked when the upgrade finished). + commands.CleanupUpgradeSidecars() + // Start background update check early so it runs during command execution updateCheck = commands.StartUpdateCheck() diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index ac05bda3f..0d8f2ad10 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -396,38 +396,98 @@ func formatSDKProvenance(p *version.SDKProvenance, verbose bool) Check { return check } -// fetchLatestVersion attempts to fetch the latest release version from GitHub. +// releaseAsset is a downloadable file attached to a GitHub release. +type releaseAsset struct { + Name string `json:"name"` + DownloadURL string `json:"browser_download_url"` +} + +// releaseInfo describes the latest GitHub release: its version (tag without +// the "v" prefix) and downloadable assets. +type releaseInfo struct { + Version string + Assets []releaseAsset +} + +// asset returns the release asset with the given exact name. +func (r releaseInfo) asset(name string) (releaseAsset, bool) { + for _, a := range r.Assets { + if a.Name == name { + return a, true + } + } + return releaseAsset{}, false +} + +// releasesLatestURL is swappable so tests can point release fetching at a +// local httptest server. +var releasesLatestURL = "https://api.github.com/repos/basecamp/basecamp-cli/releases/latest" + +// fetchLatestRelease fetches the latest release metadata from GitHub. // Uses its own context since version checks are best-effort and independent of caller lifecycle. -func fetchLatestVersion() (string, error) { //nolint:contextcheck // intentionally creates bounded context for best-effort check +func fetchLatestRelease() (releaseInfo, error) { //nolint:contextcheck // intentionally creates bounded context for best-effort check ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/repos/basecamp/basecamp-cli/releases/latest", nil) + req, err := http.NewRequestWithContext(ctx, "GET", releasesLatestURL, nil) if err != nil { - return "", err + return releaseInfo{}, err } req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", version.UserAgent()) + attachGitHubAuth(req) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { - return "", err + return releaseInfo{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("unexpected status: %d", resp.StatusCode) + return releaseInfo{}, fmt.Errorf("unexpected status: %d", resp.StatusCode) } var release struct { - TagName string `json:"tag_name"` + TagName string `json:"tag_name"` + Assets []releaseAsset `json:"assets"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&release); err != nil { // 1 MB limit - return "", err + return releaseInfo{}, err } // Strip "v" prefix if present - return strings.TrimPrefix(release.TagName, "v"), nil + return releaseInfo{ + Version: strings.TrimPrefix(release.TagName, "v"), + Assets: release.Assets, + }, nil +} + +// attachGitHubAuth adds a bearer token to api.github.com requests when the +// environment carries one (GH_TOKEN, then GITHUB_TOKEN — the gh CLI's +// precedence). Anonymous requests are rate-limited per source IP, which +// bites shared CI runner egress. Host-guarded so a token is never sent to +// any other host (tests point releasesLatestURL at local servers). +func attachGitHubAuth(req *http.Request) { + if req.URL.Host != "api.github.com" { + return + } + for _, name := range []string{"GH_TOKEN", "GITHUB_TOKEN"} { + if token := os.Getenv(name); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + return + } + } +} + +// fetchLatestVersion returns just the latest release version, for callers +// that don't need asset metadata. +func fetchLatestVersion() (string, error) { + release, err := releaseFetcher() + if err != nil { + return "", err + } + return release.Version, nil } // checkRuntime returns Go runtime information. diff --git a/internal/commands/doctor_test.go b/internal/commands/doctor_test.go index b88ad0f9e..54f7d15fc 100644 --- a/internal/commands/doctor_test.go +++ b/internal/commands/doctor_test.go @@ -5,6 +5,7 @@ import ( "context" "debug/pe" "encoding/binary" + "net/http" "os" "path/filepath" "runtime" @@ -877,3 +878,32 @@ func TestDoctorVerboseShowsBC3Scope(t *testing.T) { assert.Contains(t, check.Message, "scope: read", "BC3 scope should appear in verbose output") assert.Contains(t, check.Message, "type: bc3") } + +// The release lookup attaches a GitHub token only for api.github.com — a +// token must never be sent to any other host (tests and mirrors point +// releasesLatestURL elsewhere), and GH_TOKEN takes precedence over +// GITHUB_TOKEN, matching the gh CLI. +func TestAttachGitHubAuthHostGuardAndPrecedence(t *testing.T) { + t.Setenv("GH_TOKEN", "gh-tok") + t.Setenv("GITHUB_TOKEN", "action-tok") + + api, err := http.NewRequestWithContext(context.Background(), "GET", "https://api.github.com/repos/basecamp/basecamp-cli/releases/latest", nil) + require.NoError(t, err) + attachGitHubAuth(api) + assert.Equal(t, "Bearer gh-tok", api.Header.Get("Authorization")) + + other, err := http.NewRequestWithContext(context.Background(), "GET", "http://127.0.0.1:9999/releases/latest", nil) + require.NoError(t, err) + attachGitHubAuth(other) + assert.Empty(t, other.Header.Get("Authorization")) +} + +func TestAttachGitHubAuthFallsBackToGithubToken(t *testing.T) { + t.Setenv("GH_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "action-tok") + + req, err := http.NewRequestWithContext(context.Background(), "GET", "https://api.github.com/repos/basecamp/basecamp-cli/releases/latest", nil) + require.NoError(t, err) + attachGitHubAuth(req) + assert.Equal(t, "Bearer action-tok", req.Header.Get("Authorization")) +} diff --git a/internal/commands/testdata/selfupdate/checksums.txt b/internal/commands/testdata/selfupdate/checksums.txt new file mode 100644 index 000000000..689e3f911 --- /dev/null +++ b/internal/commands/testdata/selfupdate/checksums.txt @@ -0,0 +1,27 @@ +972f836908694984e80ca73291d8ee122e9ed72973cddf796bcc4ee480fe66e4 basecamp-cli_0.8.1_linux_amd64.apk +90a3f0bd8b223ad08e0c25a54a0f92c1c70c20c576ed30a070912970905e4407 basecamp-cli_0.8.1_linux_amd64.deb +47127ad2e10048af2f25141a68a92546f07c288c85b449f4e5eb783ab2da7f8b basecamp-cli_0.8.1_linux_amd64.rpm +fe26909515986d25e9f95263b0ff26f1396cc15794c37502a700d9fd3028e08d basecamp-cli_0.8.1_linux_arm64.apk +ad3168776233c925f95861d4b2a4894ecb1f7003f04abfdafd6a9bd4d166623e basecamp-cli_0.8.1_linux_arm64.deb +6e87adb21cd1b925be6f6387c9d77cc3bd448b68a81745034466f91db2267157 basecamp-cli_0.8.1_linux_arm64.rpm +c1f332b75c6d04b3b405b789f3d65d2498af9390894051224711c6cf3de1a0c2 basecamp_0.8.1_darwin_amd64.tar.gz +1bcff2615c15f2e4b7cb8c3f74c1fd207c50f42bede8c9626b932e45773caf98 basecamp_0.8.1_darwin_amd64.tar.gz.sbom.json +5d512945be75066735d27025dc863d0e2177cc5913250bc676e5f04bcd366c41 basecamp_0.8.1_darwin_arm64.tar.gz +39d45f99b2b3ca0f76433ea7a7326fe9d497f0f48afef1f4c12adf11d779a6a3 basecamp_0.8.1_darwin_arm64.tar.gz.sbom.json +1226a3cccc91e97d293c49c836e508bc6982d03bb70df63cd470e40db4025aa8 basecamp_0.8.1_freebsd_amd64.tar.gz +42424a058452c220badf5bfc16a9d4576a6674877ae952fa3c8542d339252276 basecamp_0.8.1_freebsd_amd64.tar.gz.sbom.json +3b814183fe9ef2da0adb55a6d55c87c89017aae06726459f1535e9d65c375a7c basecamp_0.8.1_freebsd_arm64.tar.gz +ba50a985c75d7c911f567761c56017d190d034799c54d29a2ff8e5fe1fdde6b2 basecamp_0.8.1_freebsd_arm64.tar.gz.sbom.json +25fb5468f8708879ab7daa7a697871f9b6b9b9fb3e45d8339d13b9773c0a8d99 basecamp_0.8.1_linux_amd64.tar.gz +3e04aa513a851eafaa5c27885675ac42ceaad29d651109e5061327a46e459433 basecamp_0.8.1_linux_amd64.tar.gz.sbom.json +94289551a8e0e3c25919cab74a2abe702accc82abcff17548b127e1ca41a7be4 basecamp_0.8.1_linux_arm64.tar.gz +a0d85ae1e835f5ea834cf4555b2031a2b56ad0340c90b65b0860ed38b4ffc28e basecamp_0.8.1_linux_arm64.tar.gz.sbom.json +6278db9efe5e6f874932b03c91cbf33e0d92c7c22e68c80a2dc496a0e25b15a1 basecamp_0.8.1_openbsd_amd64.tar.gz +4d947d308a8a4f34cf920ad298adc61a5497b1bde988728ea81be71e5407020e basecamp_0.8.1_openbsd_amd64.tar.gz.sbom.json +3c15bac00972c49d7fbbc57f13570a9eb9effcfccb4d7e37bc8abe010ba72bee basecamp_0.8.1_openbsd_arm64.tar.gz +43f6b14a2dff0ca408c1cfb286c801aea9405beb6b1575def0a2e43d0aa371eb basecamp_0.8.1_openbsd_arm64.tar.gz.sbom.json +ff53cc1b9b6c84a18b67244f52e6ebd607b2c5ca55e8c394f6a672a07c208628 basecamp_0.8.1_windows_amd64.zip +0c41a3a65c07b67bd96adc0898c1dc23ddb87aa55c3b543bc7014bc0fd1c00f3 basecamp_0.8.1_windows_amd64.zip.sbom.json +aa84ac9de1f423fec4a46dde3322edc90da8c4e203cf44502c566ce312834433 basecamp_0.8.1_windows_arm64.zip +0574e5fe755adb85aa85919e654381591f5eb38a90445343c776cac92a15ea32 basecamp_0.8.1_windows_arm64.zip.sbom.json +40b046e048539f72825991e49343fefc874be810ebd4f0c77ae626df226afe6e basecamp_installer.ps1 diff --git a/internal/commands/testdata/selfupdate/checksums.txt.bundle b/internal/commands/testdata/selfupdate/checksums.txt.bundle new file mode 100644 index 000000000..c71665feb --- /dev/null +++ b/internal/commands/testdata/selfupdate/checksums.txt.bundle @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json", "verificationMaterial":{"certificate":{"rawBytes":"MIIHGDCCBp2gAwIBAgIUN/TQfBF/VOxr3FvFStnY68pluyEwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwODAzMjIzNzQzWhcNMjYwODAzMjI0NzQzWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnme8z2xTXr3creA8qPVoYRfPkpHFkgI8cqlKFtudJBFu+NT1WKob91qQMDOvduqERDd1jW+O3aWU+jjZJEvqx6OCBbwwggW4MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUXlpqCB4bITTWc3bIzYFZOBEmpLIwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wZQYDVR0RAQH/BFswWYZXaHR0cHM6Ly9naXRodWIuY29tL2Jhc2VjYW1wL2Jhc2VjYW1wLWNsaS8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL3RhZ3MvdjAuOC4xMDkGCisGAQQBg78wAQEEK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wEgYKKwYBBAGDvzABAgQEcHVzaDA2BgorBgEEAYO/MAEDBChjZWExNDc1MDgwOTMyYTJkNWVmMGE5NmYxNDAyOWVhZTM5NTZkYzZmMBUGCisGAQQBg78wAQQEB1JlbGVhc2UwIwYKKwYBBAGDvzABBQQVYmFzZWNhbXAvYmFzZWNhbXAtY2xpMB4GCisGAQQBg78wAQYEEHJlZnMvdGFncy92MC44LjEwOwYKKwYBBAGDvzABCAQtDCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMGcGCisGAQQBg78wAQkEWQxXaHR0cHM6Ly9naXRodWIuY29tL2Jhc2VjYW1wL2Jhc2VjYW1wLWNsaS8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL3RhZ3MvdjAuOC4xMDgGCisGAQQBg78wAQoEKgwoY2VhMTQ3NTA4MDkzMmEyZDVlZjBhOTZmMTQwMjllYWUzOTU2ZGM2ZjAdBgorBgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwOAYKKwYBBAGDvzABDAQqDChodHRwczovL2dpdGh1Yi5jb20vYmFzZWNhbXAvYmFzZWNhbXAtY2xpMDgGCisGAQQBg78wAQ0EKgwoY2VhMTQ3NTA4MDkzMmEyZDVlZjBhOTZmMTQwMjllYWUzOTU2ZGM2ZjAgBgorBgEEAYO/MAEOBBIMEHJlZnMvdGFncy92MC44LjEwGgYKKwYBBAGDvzABDwQMDAoxMTMxMzgxMjc0MCsGCisGAQQBg78wARAEHQwbaHR0cHM6Ly9naXRodWIuY29tL2Jhc2VjYW1wMBUGCisGAQQBg78wAREEBwwFMTMxMzEwZwYKKwYBBAGDvzABEgRZDFdodHRwczovL2dpdGh1Yi5jb20vYmFzZWNhbXAvYmFzZWNhbXAtY2xpLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvdGFncy92MC44LjEwOAYKKwYBBAGDvzABEwQqDChjZWExNDc1MDgwOTMyYTJkNWVmMGE5NmYxNDAyOWVhZTM5NTZkYzZmMBQGCisGAQQBg78wARQEBgwEcHVzaDBcBgorBgEEAYO/MAEVBE4MTGh0dHBzOi8vZ2l0aHViLmNvbS9iYXNlY2FtcC9iYXNlY2FtcC1jbGkvYWN0aW9ucy9ydW5zLzMwODU4NTI1NjEzL2F0dGVtcHRzLzEwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwFwYKKwYBBAGDvzABFwQJDAdyZWxlYXNlMD4GCisGAQQBg78wARgEMAwucmVwbzpiYXNlY2FtcC9iYXNlY2FtcC1jbGk6ZW52aXJvbm1lbnQ6cmVsZWFzZTCBiQYKKwYBBAHWeQIEAgR7BHkAdwB1AN09MGrGxxEyYxkeHJlnNwKiSl643jyt/4eKcoAvKe6OAAABn8nGmqoAAAQDAEYwRAIgKjiALAb/tExH3ocIUjp9FbOCaGHi/NnFYkW8roV3buUCIGKlGwS6RZBkvkz/EF+NDNobCLWsK4LZivc94l4xt020MAoGCCqGSM49BAMDA2kAMGYCMQDZCVzzet3rjs77ibSMrQoXXxystH03YWYm6oVl+Q4ZEE4tvgKumADojCfRVUi39TUCMQCYujtWJGYDTWayjHr18RZnLuUDGKzWchwNlWn9nO8drerkuG4DSSNNrfTv1EQiX1s="}, "tlogEntries":[{"logIndex":"2337395102", "logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion":{"kind":"hashedrekord", "version":"0.0.1"}, "integratedTime":"1785796664", "inclusionPromise":{"signedEntryTimestamp":"MEUCICi4H8VcMlbD/CbRHsxU8czeIuiWpLgjlL/+WXYXOImhAiEA95qteS8+rlQE5EjIpw0udmfV38vnsSTioCY9I6zuZuM="}, "inclusionProof":{"logIndex":"2215490840", "rootHash":"B/Jwhlk+/916ku3DEoN5kE4u/WsYSc0jFGYEWfb3OV8=", "treeSize":"2215490844", "hashes":["2WseHRkvymxcu/lQYwzhBsc+DsIxizLnciW4z4tmM1M=", "g+L2yYFK5twkoNZGQSHYTkRxu+LPqwsrEBvku2HCJGE=", "+WZLBfKKrzD/ZSCVQfMWZp4CdPT81DuCn98EFgyfr18=", "emnGCifOVsbzjh2CV8hd0v2bLbZ+px7jQ54diUUVVr4=", "VG75TD3ijnXI/5KQmv6lHyc8e7seaEQPdsfhYkMUriI=", "E/F85t6oxWAMV1kctlYOc689/A0il4wzQKBj3o81/DA=", "uM7zeQEliYRxNPH6HfuMn99lRpTC3G6t+YR7mAddFLA=", "XY/D0OCEoeSTJsrLSM3GArLHg4bw549SlMvMPMHMN1k=", "ngqvZYHC+ZOF6hJPXXWn3mQTmIqzKRBb0iK+vuamUgk=", "HyVhJxnPehVMZl/lYAULPCfYfNO1NdWKx6wS0mH7/Zo=", "zUnGDfk0c0mfimGU4jiyyxGpgv+YgtxwWQjQbI6PfIc=", "lmMvpZviYnEJ/y2lKZ6OW8/vTVa6AzxLnK8tdUAGrLk=", "i5Zl8FZrDwxCDv2e2DNO2M8JvpR/c11ElvCZS53/teA=", "xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="], "checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2215490844\nB/Jwhlk+/916ku3DEoN5kE4u/WsYSc0jFGYEWfb3OV8=\n\n— rekor.sigstore.dev wNI9ajBEAiB3DXJP5Jy0KDcsFyOtG3J9J7RnHkOXjenwz8bd6TZbOQIgWMWY5OCfatqVynFOjiv1sD6QhRA5k2GMo5LJ3SgA3xE=\n"}}, "canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiIyMTFjNGM3ZjBhODIzZmZjYzE3ZmQ4Yzg4MWFjNzlmNWVhNWYyZDY2MDliMDdhMTFmYTU4YzBkYWFiMzhhYmU2In19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FWUNJUUR6Wlh0bHBuUEtKbnV4Mko0V21aakRPR0ZXZG9VV1E0N3hFMDgzemJPZndBSWhBSkI1Q09nMnpLNk5DSE0xaEJZUGhzcHVzWmpnNlRqUzBoQUs2V3hzajBXVCIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVaEhSRU5EUW5BeVowRjNTVUpCWjBsVlRpOVVVV1pDUmk5V1QzaHlNMFoyUmxOMGJsazJPSEJzZFhsRmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFpkMDlFUVhwTmFrbDZUbnBSZWxkb1kwNU5hbGwzVDBSQmVrMXFTVEJPZWxGNlYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZ1YldVNGVqSjRWRmh5TTJOeVpVRTRjVkJXYjFsU1psQnJjRWhHYTJkSk9HTnhiRXNLUm5SMVpFcENSblVyVGxReFYwdHZZamt4Y1ZGTlJFOTJaSFZ4UlZKRVpERnFWeXRQTTJGWFZTdHFhbHBLUlhaeGVEWlBRMEppZDNkbloxYzBUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZZYkhCeENrTkNOR0pKVkZSWFl6TmlTWHBaUmxwUFFrVnRjRXhKZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDFwUldVUldVakJTUVZGSUwwSkdjM2RYV1ZwWVlVaFNNR05JVFRaTWVUbHVZVmhTYjJSWFNYVlpNamwwVERKS2FHTXlWbXBaVnpGM1RESkthQXBqTWxacVdWY3hkMHhYVG5OaFV6aDFXakpzTUdGSVZtbE1NMlIyWTIxMGJXSkhPVE5qZVRsNVdsZDRiRmxZVG14TWJteDBZa1ZDZVZwWFducE1NMUpvQ2xvelRYWmtha0YxVDBNMGVFMUVhMGREYVhOSFFWRlJRbWMzT0hkQlVVVkZTekpvTUdSSVFucFBhVGgyWkVjNWNscFhOSFZaVjA0d1lWYzVkV041Tlc0S1lWaFNiMlJYU2pGak1sWjVXVEk1ZFdSSFZuVmtRelZxWWpJd2QwVm5XVXRMZDFsQ1FrRkhSSFo2UVVKQloxRkZZMGhXZW1GRVFUSkNaMjl5UW1kRlJRcEJXVTh2VFVGRlJFSkRhR3BhVjBWNFRrUmpNVTFFWjNkUFZFMTVXVlJLYTA1WFZtMU5SMFUxVG0xWmVFNUVRWGxQVjFab1dsUk5OVTVVV210WmVscHRDazFDVlVkRGFYTkhRVkZSUW1jM09IZEJVVkZGUWpGS2JHSkhWbWhqTWxWM1NYZFpTMHQzV1VKQ1FVZEVkbnBCUWtKUlVWWlpiVVo2V2xkT2FHSllRWFlLV1cxR2VscFhUbWhpV0VGMFdUSjRjRTFDTkVkRGFYTkhRVkZSUW1jM09IZEJVVmxGUlVoS2JGcHVUWFprUjBadVkzazVNazFETkRSTWFrVjNUM2RaU3dwTGQxbENRa0ZIUkhaNlFVSkRRVkYwUkVOMGIyUklVbmRqZW05MlRETlNkbUV5Vm5WTWJVWnFaRWRzZG1KdVRYVmFNbXd3WVVoV2FXUllUbXhqYlU1MkNtSnVVbXhpYmxGMVdUSTVkRTFIWTBkRGFYTkhRVkZSUW1jM09IZEJVV3RGVjFGNFdHRklVakJqU0UwMlRIazVibUZZVW05a1YwbDFXVEk1ZEV3eVNtZ0tZekpXYWxsWE1YZE1Na3BvWXpKV2FsbFhNWGRNVjA1ellWTTRkVm95YkRCaFNGWnBURE5rZG1OdGRHMWlSemt6WTNrNWVWcFhlR3haV0U1c1RHNXNkQXBpUlVKNVdsZGFla3d6VW1oYU0wMTJaR3BCZFU5RE5IaE5SR2RIUTJselIwRlJVVUpuTnpoM1FWRnZSVXRuZDI5Wk1sWm9UVlJSTTA1VVFUUk5SR3Q2Q2sxdFJYbGFSRlpzV21wQ2FFOVVXbTFOVkZGM1RXcHNiRmxYVlhwUFZGVXlXa2ROTWxwcVFXUkNaMjl5UW1kRlJVRlpUeTlOUVVWTVFrRTRUVVJYWkhBS1pFZG9NVmxwTVc5aU0wNHdXbGRSZDA5QldVdExkMWxDUWtGSFJIWjZRVUpFUVZGeFJFTm9iMlJJVW5kamVtOTJUREprY0dSSGFERlphVFZxWWpJd2RncFpiVVo2V2xkT2FHSllRWFpaYlVaNldsZE9hR0pZUVhSWk1uaHdUVVJuUjBOcGMwZEJVVkZDWnpjNGQwRlJNRVZMWjNkdldUSldhRTFVVVROT1ZFRTBDazFFYTNwTmJVVjVXa1JXYkZwcVFtaFBWRnB0VFZSUmQwMXFiR3haVjFWNlQxUlZNbHBIVFRKYWFrRm5RbWR2Y2tKblJVVkJXVTh2VFVGRlQwSkNTVTBLUlVoS2JGcHVUWFprUjBadVkzazVNazFETkRSTWFrVjNSMmRaUzB0M1dVSkNRVWRFZG5wQlFrUjNVVTFFUVc5NFRWUk5lRTE2WjNoTmFtTXdUVU56UndwRGFYTkhRVkZSUW1jM09IZEJVa0ZGU0ZGM1ltRklVakJqU0UwMlRIazVibUZZVW05a1YwbDFXVEk1ZEV3eVNtaGpNbFpxV1ZjeGQwMUNWVWREYVhOSENrRlJVVUpuTnpoM1FWSkZSVUozZDBaTlZFMTRUWHBGZDFwM1dVdExkMWxDUWtGSFJIWjZRVUpGWjFKYVJFWmtiMlJJVW5kamVtOTJUREprY0dSSGFERUtXV2sxYW1JeU1IWlpiVVo2V2xkT2FHSllRWFpaYlVaNldsZE9hR0pZUVhSWk1uaHdUSGsxYm1GWVVtOWtWMGwyWkRJNWVXRXlXbk5pTTJSNlRETktiQXBpUjFab1l6SlZkV1ZYTVhOUlNFcHNXbTVOZG1SSFJtNWplVGt5VFVNME5FeHFSWGRQUVZsTFMzZFpRa0pCUjBSMmVrRkNSWGRSY1VSRGFHcGFWMFY0Q2s1RVl6Rk5SR2QzVDFSTmVWbFVTbXRPVjFadFRVZEZOVTV0V1hoT1JFRjVUMWRXYUZwVVRUVk9WRnByV1hwYWJVMUNVVWREYVhOSFFWRlJRbWMzT0hjS1FWSlJSVUpuZDBWalNGWjZZVVJDWTBKbmIzSkNaMFZGUVZsUEwwMUJSVlpDUlRSTlZFZG9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWFRcFpXRTVzV1RKR2RHTkRPV2xaV0U1c1dUSkdkR05ETVdwaVIydDJXVmRPTUdGWE9YVmplVGw1WkZjMWVreDZUWGRQUkZVMFRsUkpNVTVxUlhwTU1rWXdDbVJIVm5SalNGSjZUSHBGZDBabldVdExkMWxDUWtGSFJIWjZRVUpHWjFGSlJFRmFkMlJYU25OaFYwMTNSbmRaUzB0M1dVSkNRVWRFZG5wQlFrWjNVVW9LUkVGa2VWcFhlR3haV0U1c1RVUTBSME5wYzBkQlVWRkNaemM0ZDBGU1owVk5RWGQxWTIxV2QySjZjR2xaV0U1c1dUSkdkR05ET1dsWldFNXNXVEpHZEFwalF6RnFZa2RyTmxwWE5USmhXRXAyWW0weGJHSnVVVFpqYlZaeldsZEdlbHBVUTBKcFVWbExTM2RaUWtKQlNGZGxVVWxGUVdkU04wSklhMEZrZDBJeENrRk9NRGxOUjNKSGVIaEZlVmw0YTJWSVNteHVUbmRMYVZOc05qUXphbmwwTHpSbFMyTnZRWFpMWlRaUFFVRkJRbTQ0YmtkdGNXOUJRVUZSUkVGRldYY0tVa0ZKWjB0cWFVRk1RV0l2ZEVWNFNETnZZMGxWYW5BNVJtSlBRMkZIU0drdlRtNUdXV3RYT0hKdlZqTmlkVlZEU1VkTGJFZDNVelpTV2tKcmRtdDZMd3BGUml0T1JFNXZZa05NVjNOTE5FeGFhWFpqT1RSc05IaDBNREl3VFVGdlIwTkRjVWRUVFRRNVFrRk5SRUV5YTBGTlIxbERUVkZFV2tOV2VucGxkRE55Q21wek56ZHBZbE5OY2xGdldGaDRlWE4wU0RBeldWZFpiVFp2Vm13clVUUmFSVVUwZEhablMzVnRRVVJ2YWtObVVsWlZhVE01VkZWRFRWRkRXWFZxZEZjS1NrZFpSRlJYWVhscVNISXhPRkphYmt4MVZVUkhTM3BYWTJoM1RteFhiamx1VHpoa2NtVnlhM1ZITkVSVFUwNU9jbVpVZGpGRlVXbFlNWE05Q2kwdExTMHRSVTVFSUVORlVsUkpSa2xEUVZSRkxTMHRMUzBLIn19fX0="}], "timestampVerificationData":{"rfc3161Timestamps":[{"signedTimestamp":"MIICyTADAgEAMIICwAYJKoZIhvcNAQcCoIICsTCCAq0CAQMxDTALBglghkgBZQMEAgEwgbcGCyqGSIb3DQEJEAEEoIGnBIGkMIGhAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgN1tp/oH8YeHQnYiazifcgEH0zZ8ceZt27hBoaTLmETsCFGV2vmwGLLKRmupXb0TGVmmunu03GA8yMDI2MDgwMzIyMzc0NFowAwIBAaAypDAwLjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MRUwEwYDVQQDEwxzaWdzdG9yZS10c2GgADGCAdswggHXAgEBMFEwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZAIUOhNULwyQYe68wUMvy4qOiyojiwwwCwYJYIZIAWUDBAIBoIH8MBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMjYwODAzMjIzNzQ0WjAvBgkqhkiG9w0BCQQxIgQgqbX8dryBs1vP/2Q/9u1tl+huN9FlfZYrQzABYw5NPT4wgY4GCyqGSIb3DQEJEAIvMX8wfTB7MHkEIIX5J7wHq2LKw7RDVsEO/IGyxog/2nq55thw2dE6zQW3MFUwPaQ7MDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAoGCCqGSM49BAMCBGcwZQIwbb4ELqzEj1Awh8Vxl8FQ5Nmo3eEnuzkDBA1qBGoZdwl5OzuGDPjnpPYDHfpqVbATAjEAydu1E9pW1HrTNtEuNB3AOuQkqxZJWr7Qebn2rRCA/wI59DMIwy+7FX1S3z6F7Y6a"}]}}, "messageSignature":{"messageDigest":{"algorithm":"SHA2_256", "digest":"IRxMfwqCP/zBf9jIgax59epfLWYJsHoR+ljA2qs4q+Y="}, "signature":"MEYCIQDzZXtlpnPKJnux2J4WmZjDOGFWdoUWQ47xE083zbOfwAIhAJB5COg2zK6NCHM1hBYPhspusZjg6TjS0hAK6Wxsj0WT"}} \ No newline at end of file diff --git a/internal/commands/testdata/selfupdate/trusted_root.json b/internal/commands/testdata/selfupdate/trusted_root.json new file mode 100644 index 000000000..effb0a19e --- /dev/null +++ b/internal/commands/testdata/selfupdate/trusted_root.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +} diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go index f164ade0f..9fe822d3b 100644 --- a/internal/commands/upgrade.go +++ b/internal/commands/upgrade.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/spf13/cobra" + "golang.org/x/mod/semver" "github.com/basecamp/basecamp-cli/internal/appctx" "github.com/basecamp/basecamp-cli/internal/output" @@ -33,7 +34,9 @@ const ( // versionChecker and package manager helpers abstract external checks for testability. var ( versionChecker = fetchLatestVersion + releaseFetcher = fetchLatestRelease executablePathResolver = resolvedExecutablePath + brewPrefixResolver = resolveBrewPrefix scoopPrefixResolver = resolveScoopPrefix homebrewChecker = isHomebrew legacyHomebrewCasker = hasLegacyHomebrewCask @@ -73,11 +76,16 @@ func runUpgrade(cmd *cobra.Command, args []string) error { fmt.Fprintf(w, "Current version: %s\n", current) fmt.Fprint(w, "Checking for updates… ") - latest, err := versionChecker() + release, err := releaseFetcher() if err != nil { fmt.Fprintln(w, "failed") - return fmt.Errorf("could not check for updates: %w", err) + return &output.Error{ + Code: "upgrade_failed", + Message: fmt.Sprintf("could not check for updates: %v", err), + Hint: "Check network access to api.github.com and retry. In CI, set GITHUB_TOKEN to avoid anonymous rate limits.", + } } + latest := release.Version if !isUpdateAvailable(current, latest) { fmt.Fprintln(w, "already up to date") @@ -93,24 +101,28 @@ func runUpgrade(cmd *cobra.Command, args []string) error { if homebrewChecker(ctx) { fmt.Fprintln(w, "Upgrading via Homebrew…") if err := homebrewUpgrader(ctx, w, cmd.ErrOrStderr()); err != nil { - return fmt.Errorf("brew upgrade failed for cask %s: %w", homebrewCask, err) + return &output.Error{ + Code: "upgrade_failed", + Message: fmt.Sprintf("brew upgrade failed for cask %s: %v", homebrewCask, err), + Hint: fmt.Sprintf("Run manually for detail: brew upgrade --cask %s", homebrewCask), + } } - return app.OK( - map[string]string{"status": "upgraded", "from": current, "to": latest}, - output.WithSummary(fmt.Sprintf("Upgraded %s → %s", current, latest)), - ) + return confirmManagedUpgrade(ctx, app, "homebrew", homebrewBinaryPath(ctx), current, latest, + fmt.Sprintf("brew reinstall --cask %s", homebrewCask)) } if scoopChecker(ctx) { global := scoopGlobalScopeChecker(ctx) fmt.Fprintln(w, "Upgrading via Scoop…") if err := scoopUpgrader(ctx, global, w, cmd.ErrOrStderr()); err != nil { - return fmt.Errorf("scoop update failed for app %s: %w", scoopApp, err) + return &output.Error{ + Code: "upgrade_failed", + Message: fmt.Sprintf("scoop update failed for app %s: %v", scoopApp, err), + Hint: fmt.Sprintf("Run manually for detail: scoop update%s %s", scoopGlobalFlag(global), scoopApp), + } } - return app.OK( - map[string]string{"status": "upgraded", "from": current, "to": latest}, - output.WithSummary(fmt.Sprintf("Upgraded %s → %s", current, latest)), - ) + return confirmManagedUpgrade(ctx, app, "scoop", scoopBinaryPath(ctx), current, latest, + fmt.Sprintf("scoop uninstall%s %s && scoop install%s %s", scoopGlobalFlag(global), scoopApp, scoopGlobalFlag(global), scoopApp)) } if legacyHomebrewCasker(ctx) { @@ -118,16 +130,11 @@ func runUpgrade(cmd *cobra.Command, args []string) error { fmt.Fprintln(w, "The CLI cask has been renamed. To upgrade, run:") fmt.Fprintf(w, " brew uninstall --cask %s\n", legacyHomebrewCask) fmt.Fprintf(w, " brew install --cask %s\n", homebrewCask) - return app.OK( - map[string]string{ - "status": "migration_required", - "from": current, - "to": latest, - "legacy_cask": legacyHomebrewCask, - "replacement": homebrewCask, - }, - output.WithSummary("Homebrew cask rename detected — manual migration required"), - ) + return &output.Error{ + Code: "upgrade_required", + Message: fmt.Sprintf("update available (%s → %s) but the Homebrew cask was renamed — migration required", current, latest), + Hint: fmt.Sprintf("Run: brew uninstall --cask %s && brew install --cask %s", legacyHomebrewCask, homebrewCask), + } } if legacyScoopChecker(ctx) { @@ -136,28 +143,138 @@ func runUpgrade(cmd *cobra.Command, args []string) error { fmt.Fprintln(w, "The CLI Scoop manifest has been renamed. To upgrade, run:") fmt.Fprintf(w, " scoop uninstall%s %s\n", scoopGlobalFlag(global), legacyScoopApp) fmt.Fprintf(w, " scoop install%s %s\n", scoopGlobalFlag(global), scoopApp) - return app.OK( - map[string]string{ - "status": "migration_required", - "from": current, - "to": latest, - "legacy_manifest": legacyScoopApp, - "replacement": scoopApp, - }, - output.WithSummary("Scoop manifest rename detected — manual migration required"), + return &output.Error{ + Code: "upgrade_required", + Message: fmt.Sprintf("update available (%s → %s) but the Scoop manifest was renamed — migration required", current, latest), + Hint: fmt.Sprintf("Run: scoop uninstall%s %s && scoop install%s %s", + scoopGlobalFlag(global), legacyScoopApp, scoopGlobalFlag(global), scoopApp), + } + } + + // A `go install` build (stable or pseudo version alike) has no release + // asset lineage to swap in — the module toolchain owns it. + if goInstallChecker() { + return &output.Error{ + Code: "upgrade_required", + Message: fmt.Sprintf("update available (%s → %s) but this binary was built with go install — upgrade it the same way", current, latest), + Hint: "Run: go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest", + } + } + + target, err := selfUpdateTargetResolver() + if err != nil { + downloadURL := releaseTagURL(latest) + fmt.Fprintln(w) + fmt.Fprintf(w, "Download the latest release from:\n %s\n", downloadURL) + return &output.Error{ + Code: "upgrade_required", + Message: fmt.Sprintf("update available (%s → %s) but the running executable could not be resolved: %v", current, latest, err), + Hint: "Download manually: " + downloadURL, + } + } + + if reason, hint := selfUpdateIneligibility(target); reason != "" { + return &output.Error{ + Code: "upgrade_required", + Message: fmt.Sprintf("update available (%s → %s) but this install can't be self-updated (%s)", current, latest, reason), + Hint: hint, + } + } + + // Serialize the mutating phase. The release-metadata check above is + // read-only and runs unlocked; the lock is taken before any asset + // download or filesystem mutation so a concurrent upgrade cannot touch + // the same executable and a concurrent invocation's sidecar cleanup + // cannot reap this upgrade's live files. + lock, err := upgradeLocker(target) + if err != nil { + return errUpgradeFailedHint( + fmt.Sprintf("could not begin the upgrade: %v", err), + "If another basecamp upgrade is running, wait for it to finish and retry.", ) } + defer func() { _ = lock.Unlock() }() + + return runNativeSelfUpdate(ctx, app.OK, w, target, current, release) +} + +// confirmManagedUpgrade verifies a package-manager upgrade actually landed by +// probing the manager-derived entrypoint. No success is reported without a +// confirmed version: a probe that can't run is upgrade_unverified, a probe +// that reports anything but the latest version is upgrade_incomplete. +func confirmManagedUpgrade(ctx context.Context, app *appctx.App, method, probePath, current, latest, reinstallCmd string) error { + if probePath == "" { + return &output.Error{ + Code: "upgrade_unverified", + Message: fmt.Sprintf("%s reported success but the installed binary could not be located to confirm the version", method), + Hint: fmt.Sprintf("Run `basecamp version` to confirm; if it still reports %s, run: %s", current, reinstallCmd), + } + } + + reported, err := binaryVersionProber(ctx, probePath) + if err != nil { + return &output.Error{ + Code: "upgrade_unverified", + Message: fmt.Sprintf("%s reported success but probing %s failed: %v", method, probePath, err), + Hint: fmt.Sprintf("Run `basecamp version` to confirm; if it still reports %s, run: %s", current, reinstallCmd), + } + } + + // Semantic comparison, accepting reported >= latest: a release published + // while the manager ran can legitimately install something newer than the + // snapshot fetched at the start. An unparseable probe result fails safely + // as unconfirmed rather than pretending to know either way. + reportedSemver, latestSemver := normalizeSemver(reported), normalizeSemver(latest) + if !semver.IsValid(reportedSemver) || !semver.IsValid(latestSemver) { + return &output.Error{ + Code: "upgrade_unverified", + Message: fmt.Sprintf("%s reported success but the installed version %q could not be interpreted (expected %s)", method, reported, latest), + Hint: fmt.Sprintf("Run `basecamp version` to confirm; if it still reports %s, run: %s", current, reinstallCmd), + } + } + if semver.Compare(reportedSemver, latestSemver) < 0 { + return &output.Error{ + Code: "upgrade_incomplete", + Message: fmt.Sprintf("%s exited successfully but basecamp still reports %s (expected %s, upgrading from %s)", method, reported, latest, current), + Hint: "Try: " + reinstallCmd, + } + } - downloadURL := fmt.Sprintf("https://github.com/basecamp/basecamp-cli/releases/tag/v%s", latest) - fmt.Fprintln(w) - fmt.Fprintf(w, "Download the latest release from:\n") - fmt.Fprintf(w, " %s\n", downloadURL) return app.OK( - map[string]string{"status": "update_available", "from": current, "to": latest, "download_url": downloadURL}, - output.WithSummary(fmt.Sprintf("Update available: %s → %s", current, latest)), + map[string]string{"status": "upgraded", "from": current, "to": reported, "method": method}, + output.WithSummary(fmt.Sprintf("Upgraded %s → %s", current, reported)), ) } +// homebrewBinaryPath derives the brew-managed entrypoint from `brew --prefix`. +// os.Executable is deliberately not used here: Go documents it may return the +// symlink or the resolved target — possibly stale after the cask swap. +func homebrewBinaryPath(ctx context.Context) string { + prefix, err := brewPrefixResolver(ctx) + if err != nil || prefix == "" { + return "" + } + return filepath.Join(prefix, "bin", "basecamp") +} + +// scoopBinaryPath derives the scoop-managed entrypoint from `scoop prefix` +// (the loaded module on Windows is the exe, not the shim, and may be stale). +func scoopBinaryPath(ctx context.Context) string { + prefix, ok := scoopPrefixResolver(ctx, scoopApp) + if !ok || prefix == "" { + return "" + } + return filepath.Join(filepath.FromSlash(prefix), "basecamp.exe") +} + +func resolveBrewPrefix(ctx context.Context) (string, error) { + out, err := exec.CommandContext(ctx, "brew", "--prefix").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + func upgradeHomebrew(ctx context.Context, stdout io.Writer, stderr io.Writer) error { upgrade := exec.CommandContext(ctx, "brew", "upgrade", "--cask", homebrewCask) upgrade.Stdout = stdout diff --git a/internal/commands/upgrade_selfupdate.go b/internal/commands/upgrade_selfupdate.go new file mode 100644 index 000000000..5ff99adeb --- /dev/null +++ b/internal/commands/upgrade_selfupdate.go @@ -0,0 +1,791 @@ +package commands + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/gofrs/flock" + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/sigstore/sigstore-go/pkg/verify" + + "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/version" +) + +// Native self-update: download the platform release asset, verify authenticity +// (Sigstore bundle over checksums.txt) and integrity (sha256), then swap the +// running executable transactionally. Applies only to installer-script/tarball +// installs under the user's home directory — package-manager installs are +// delegated or refused with method-specific instructions. + +const ( + // upgradeStagePrefix names every transient file the self-update writes into + // the install directory (staged binaries, write probes). Leftovers from a + // crash are reaped by CleanupUpgradeSidecars on later invocations. + upgradeStagePrefix = ".basecamp-upgrade-" + + maxArchiveBytes = 200 << 20 // downloaded release archive + maxTextAssetBytes = 1 << 20 // checksums.txt and its bundle + + sigstoreOIDCIssuer = "https://token.actions.githubusercontent.com" +) + +// maxBinaryBytes caps the uncompressed extracted binary (decompression-bomb +// guard). Variable so tests can exercise the cap without a 300MB fixture. +var maxBinaryBytes int64 = 300 << 20 + +// Self-update seams, swappable for tests (see upgrade.go for the pattern). +var ( + selfUpdateTargetResolver = resolveSelfUpdateTarget + binaryVersionProber = probeBinaryVersion + bundleVerifier = verifySigstoreBundle + goInstallChecker = version.IsGoInstall + euidResolver = os.Geteuid + homeDirResolver = os.UserHomeDir + linkFile = os.Link + renameFile = os.Rename + removeFile = os.Remove + upgradeLocker = acquireUpgradeLock + selfUpdateHTTPClient = &http.Client{Timeout: 5 * time.Minute} +) + +// upgradeLockPath names the lock file that serializes upgrades of target +// against each other and against sidecar cleanup. +func upgradeLockPath(target string) string { + return target + ".upgrade.lock" +} + +// acquireUpgradeLock takes the exclusive upgrade lock for target without +// blocking. Held across staging, replacement, verification, and rollback so +// a concurrent upgrade cannot mutate the same executable and a concurrent +// invocation's sidecar cleanup cannot reap this upgrade's live staging +// files. The lock file itself is left behind and reaped by +// CleanupUpgradeSidecars on a later invocation. +func acquireUpgradeLock(target string) (*flock.Flock, error) { + lock := flock.New(upgradeLockPath(target)) + locked, err := lock.TryLock() + if err != nil { + return nil, err + } + if !locked { + return nil, errors.New("another basecamp upgrade is already running") + } + return lock, nil +} + +// releaseWorkflowIdentity is the certificate SAN the release pipeline signs +// under (keyless cosign from the release workflow, pinned to the version tag). +func releaseWorkflowIdentity(ver string) string { + return "https://github.com/basecamp/basecamp-cli/.github/workflows/release.yml@refs/tags/v" + ver +} + +func releaseTagURL(ver string) string { + return "https://github.com/basecamp/basecamp-cli/releases/tag/v" + ver +} + +const installerHint = "curl -fsSL https://basecamp.com/install-cli | bash" + +// resolveSelfUpdateTarget returns the canonical, case-preserving path of the +// running executable. Distinct from resolvedExecutablePath, which lowercases +// for substring matching and must stay match-only. +func resolveSelfUpdateTarget() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return filepath.Abs(exe) +} + +// selfUpdateIneligibility applies the fail-closed path policy. It returns a +// non-empty reason (and a reason-specific hint) when the target must not be +// self-updated; empty reason means eligible. +func selfUpdateIneligibility(target string) (reason, hint string) { + if runtime.GOOS != "windows" && euidResolver() == 0 { + return "running_as_root", + "Re-run basecamp upgrade as the user who installed the CLI, or upgrade via your system package manager" + } + + // Nix gets its own message before the generic home test: /nix/store paths + // are immutable by design, and "outside your home" would mislead. + if strings.HasPrefix(filepath.ToSlash(target), "/nix/store/") { + return "nix_store", + "This binary lives in the Nix store. Upgrade it the Nix way, e.g.: nix profile upgrade basecamp-cli (or update your flake pin)" + } + + home, err := homeDirResolver() + if err != nil || home == "" || !pathWithin(home, target) { + return "system_path", + "This binary is outside your home directory. Upgrade with the package manager that installed it, or reinstall under your home: " + installerHint + } + + if err := probeDirWritable(filepath.Dir(target)); err != nil { + return "not_writable", + fmt.Sprintf("The install directory %s is not writable. Fix its permissions, or reinstall: %s", filepath.Dir(target), installerHint) + } + + return "", "" +} + +// pathWithin reports whether target is inside root after canonicalizing both +// paths (symlinks resolved, absolute). Containment is decided by filepath.Rel +// — never by string prefixing — so sibling-prefix dirs (/Users/jeremyx vs +// /Users/jeremy) and symlinked escapes cannot pass. +func pathWithin(rootDir, target string) bool { + canonRoot, err := canonicalPath(rootDir) + if err != nil { + return false + } + canonTarget, err := canonicalPath(target) + if err != nil { + return false + } + rel, err := filepath.Rel(canonRoot, canonTarget) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +func canonicalPath(p string) (string, error) { + resolved, err := filepath.EvalSymlinks(p) + if err != nil { + return "", err + } + return filepath.Abs(resolved) +} + +// probeDirWritable verifies dir accepts new files by actually creating one. +func probeDirWritable(dir string) error { + f, err := os.CreateTemp(dir, upgradeStagePrefix+"probe-") + if err != nil { + return err + } + name := f.Name() + _ = f.Close() + return os.Remove(name) +} + +// runNativeSelfUpdate performs the download → verify → swap → confirm flow. +// Returns app.OK on confirmed success; every other outcome is a *output.Error. +func runNativeSelfUpdate(ctx context.Context, ok func(any, ...output.ResponseOption) error, w io.Writer, target, current string, release releaseInfo) error { + latest := release.Version + + ext, binName := "tar.gz", "basecamp" + if runtime.GOOS == "windows" { + ext, binName = "zip", "basecamp.exe" + } + archiveName := fmt.Sprintf("basecamp_%s_%s_%s.%s", latest, runtime.GOOS, runtime.GOARCH, ext) + + archiveAsset, found := release.asset(archiveName) + if !found { + fmt.Fprintln(w) + fmt.Fprintf(w, "No prebuilt binary for %s/%s — download the release from:\n %s\n", runtime.GOOS, runtime.GOARCH, releaseTagURL(latest)) + return &output.Error{ + Code: "upgrade_required", + Message: fmt.Sprintf("update available (%s → %s) but release v%s has no prebuilt binary for %s/%s", current, latest, latest, runtime.GOOS, runtime.GOARCH), + Hint: "Download manually: " + releaseTagURL(latest), + } + } + checksumsAsset, found := release.asset("checksums.txt") + if !found { + return errUpgradeFailed(fmt.Sprintf("release v%s is missing checksums.txt — cannot verify the download", latest)) + } + bundleAsset, found := release.asset("checksums.txt.bundle") + if !found { + return errUpgradeFailed(fmt.Sprintf("release v%s is missing checksums.txt.bundle — cannot verify authenticity", latest)) + } + + tmpDir, err := os.MkdirTemp("", "basecamp-upgrade-") + if err != nil { + return errUpgradeFailed(fmt.Sprintf("could not create a download directory: %v", err)) + } + defer os.RemoveAll(tmpDir) + + fmt.Fprintf(w, "Downloading %s… ", archiveName) + archivePath := filepath.Join(tmpDir, archiveName) + archiveSHA, err := downloadFileSHA256(ctx, archiveAsset.DownloadURL, archivePath, maxArchiveBytes) + if err != nil { + fmt.Fprintln(w, "failed") + return errUpgradeFailed(fmt.Sprintf("download failed: %v", err)) + } + checksums, err := fetchAssetBytes(ctx, checksumsAsset.DownloadURL, maxTextAssetBytes) + if err != nil { + fmt.Fprintln(w, "failed") + return errUpgradeFailed(fmt.Sprintf("could not download checksums.txt: %v", err)) + } + bundleBytes, err := fetchAssetBytes(ctx, bundleAsset.DownloadURL, maxTextAssetBytes) + if err != nil { + fmt.Fprintln(w, "failed") + return errUpgradeFailed(fmt.Sprintf("could not download checksums.txt.bundle: %v", err)) + } + fmt.Fprintln(w, "done") + + fmt.Fprint(w, "Verifying signature… ") + if err := bundleVerifier(checksums, bundleBytes, latest); err != nil { + fmt.Fprintln(w, "failed") + return errUpgradeFailed(fmt.Sprintf("signature verification failed for checksums.txt: %v", err)) + } + expected, found := parseChecksums(checksums)[archiveName] + if !found { + fmt.Fprintln(w, "failed") + return errUpgradeFailed(fmt.Sprintf("verified checksums.txt has no entry for %s", archiveName)) + } + if !strings.EqualFold(expected, archiveSHA) { + fmt.Fprintln(w, "failed") + return errUpgradeFailed(fmt.Sprintf("checksum mismatch for %s: expected %s, got %s", archiveName, expected, archiveSHA)) + } + fmt.Fprintln(w, "ok") + + fmt.Fprint(w, "Installing… ") + staged, err := stageBinary(archivePath, ext, binName, target) + if err != nil { + fmt.Fprintln(w, "failed") + return errUpgradeFailed(fmt.Sprintf("could not stage the new binary: %v", err)) + } + defer os.Remove(staged) // no-op after a successful rename + + if info, statErr := os.Stat(target); statErr == nil { + _ = os.Chmod(staged, info.Mode().Perm()) + } + + if reported, probeErr := binaryVersionProber(ctx, staged); probeErr != nil { + fmt.Fprintln(w, "failed") + return errUpgradeFailed(fmt.Sprintf("downloaded binary failed its pre-install check: %v", probeErr)) + } else if reported != latest { + fmt.Fprintln(w, "failed") + return errUpgradeFailed(fmt.Sprintf("downloaded binary reports version %s, expected %s", reported, latest)) + } + + backup, err := replaceExecutable(runtime.GOOS, target, staged) + if err != nil { + fmt.Fprintln(w, "failed") + var cat *swapCatastropheError + if errors.As(err, &cat) { + cat.backup = preserveRecoveryArtifact(target, cat.backup) + } + return swapFailureError(target, err) + } + + if reported, probeErr := binaryVersionProber(ctx, target); probeErr != nil || reported != latest { + fmt.Fprintln(w, "failed") + detail := fmt.Sprintf("reports %s, expected %s", reported, latest) + if probeErr != nil { + detail = fmt.Sprintf("could not be probed: %v", probeErr) + } + if restoreErr := restoreBackup(runtime.GOOS, target, backup); restoreErr != nil { + kept := preserveRecoveryArtifact(target, backup) + return errUpgradeFailedHint( + fmt.Sprintf("installed binary %s, and restoring the previous version failed: %v", detail, restoreErr), + fmt.Sprintf("Your previous binary was preserved at %s — move it back to %s, or reinstall: %s", kept, target, installerHint), + ) + } + return errUpgradeFailed(fmt.Sprintf("installed binary %s — the previous version (%s) was restored", detail, current)) + } + + discardBackup(target, backup) + + fmt.Fprintln(w, "done") + fmt.Fprintf(w, "Upgraded %s → %s\n", current, latest) + return ok( + map[string]string{"status": "upgraded", "from": current, "to": latest, "method": "native"}, + output.WithSummary(fmt.Sprintf("Upgraded %s → %s", current, latest)), + ) +} + +func errUpgradeFailed(msg string) *output.Error { + return errUpgradeFailedHint(msg, "The existing install was left in place. Re-run basecamp upgrade, or reinstall: "+installerHint) +} + +func errUpgradeFailedHint(msg, hint string) *output.Error { + return &output.Error{Code: "upgrade_failed", Message: msg, Hint: hint} +} + +// downloadFileSHA256 streams url to dest, returning the sha256 of the bytes +// written. limit caps the download size. +func downloadFileSHA256(ctx context.Context, url, dest string, limit int64) (string, error) { + resp, err := httpGetOK(ctx, url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", err + } + h := sha256.New() + n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, limit+1)) + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err != nil { + return "", err + } + if n > limit { + return "", fmt.Errorf("download exceeds %d bytes", limit) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func fetchAssetBytes(ctx context.Context, url string, limit int64) ([]byte, error) { + resp, err := httpGetOK(ctx, url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("download exceeds %d bytes", limit) + } + return data, nil +} + +func httpGetOK(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", version.UserAgent()) + resp, err := selfUpdateHTTPClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("GET %s: unexpected status %d", url, resp.StatusCode) + } + return resp, nil +} + +// verifySigstoreBundle verifies the Sigstore bundle over the checksums.txt +// bytes: certificate identity pinned to the release workflow at the version +// tag, issuer pinned to GitHub OIDC, with SCT + transparency-log + observer +// timestamp thresholds. The trusted root comes from the Sigstore TUF repo +// (cached locally). +func verifySigstoreBundle(checksums, bundleBytes []byte, ver string) error { + trusted, err := sigstoreTrustedRoot() + if err != nil { + return fmt.Errorf("fetch sigstore trusted root: %w", err) + } + return verifyBundleWithRoot(trusted, checksums, bundleBytes, ver) +} + +func verifyBundleWithRoot(trusted root.TrustedMaterial, checksums, bundleBytes []byte, ver string) error { + var b bundle.Bundle + if err := b.UnmarshalJSON(bundleBytes); err != nil { + return fmt.Errorf("parse signature bundle: %w", err) + } + + verifier, err := verify.NewVerifier(trusted, + verify.WithSignedCertificateTimestamps(1), + verify.WithTransparencyLog(1), + verify.WithObserverTimestamps(1), + ) + if err != nil { + return err + } + + certID, err := verify.NewShortCertificateIdentity(sigstoreOIDCIssuer, "", releaseWorkflowIdentity(ver), "") + if err != nil { + return err + } + + _, err = verifier.Verify(&b, verify.NewPolicy( + verify.WithArtifact(bytes.NewReader(checksums)), + verify.WithCertificateIdentity(certID), + )) + return err +} + +func sigstoreTrustedRoot() (root.TrustedMaterial, error) { + opts := tuf.DefaultOptions() + if cacheDir, err := os.UserCacheDir(); err == nil { + opts.CachePath = filepath.Join(cacheDir, "basecamp", "sigstore-tuf") + } + return root.FetchTrustedRootWithOptions(opts) +} + +// parseChecksums parses goreleaser's checksums.txt: one "hash name" (or +// "hash *name") entry per line, CRLF-tolerant. +func parseChecksums(data []byte) map[string]string { + sums := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(strings.TrimRight(line, "\r")) + if len(fields) != 2 { + continue + } + name := strings.TrimPrefix(fields[1], "*") + if name == "" { + continue + } + sums[name] = strings.ToLower(fields[0]) + } + return sums +} + +// stageBinary extracts the release binary from the verified archive into the +// target's own directory (same volume ⇒ the final rename is atomic) and +// returns the staged path. +func stageBinary(archivePath, ext, binName, target string) (string, error) { + suffix := "" + if strings.HasSuffix(binName, ".exe") { + suffix = ".exe" // staged binary must be executable for the pre-install probe + } + staged := filepath.Join(filepath.Dir(target), upgradeStagePrefix+upgradeRandSuffix()+suffix) + + var err error + if ext == "zip" { + err = extractZipMember(archivePath, binName, staged) + } else { + err = extractTarGzMember(archivePath, binName, staged) + } + if err != nil { + _ = os.Remove(staged) + return "", err + } + return staged, nil +} + +func upgradeRandSuffix() string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%d", os.Getpid()) + } + return hex.EncodeToString(b[:]) +} + +// extractTarGzMember writes exactly the archive-root member named `member` to +// dest. Hardened: link and symlink entries anywhere in the archive are +// rejected, duplicate members are rejected, and the uncompressed size is +// capped. Archive entry names are never joined into paths. +func extractTarGzMember(archivePath, member, dest string) error { + f, err := os.Open(archivePath) //nolint:gosec // G304: path is our own temp download + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + found := false + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if hdr.Typeflag == tar.TypeLink || hdr.Typeflag == tar.TypeSymlink { + return fmt.Errorf("archive contains link entry %q — refusing to extract", hdr.Name) + } + if hdr.Typeflag != tar.TypeReg || path.Clean(hdr.Name) != member { + continue + } + if found { + return fmt.Errorf("archive contains duplicate %q entries", member) + } + found = true + if err := writeExtractedFile(dest, tr); err != nil { + return err + } + } + if !found { + return fmt.Errorf("archive does not contain %q at its root", member) + } + return nil +} + +func extractZipMember(archivePath, member, dest string) error { + zr, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer zr.Close() + + found := false + for _, zf := range zr.File { + if zf.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("archive contains symlink entry %q — refusing to extract", zf.Name) + } + if zf.FileInfo().IsDir() || path.Clean(zf.Name) != member { + continue + } + if found { + return fmt.Errorf("archive contains duplicate %q entries", member) + } + found = true + + rc, err := zf.Open() + if err != nil { + return err + } + writeErr := writeExtractedFile(dest, rc) + rc.Close() + if writeErr != nil { + return writeErr + } + } + if !found { + return fmt.Errorf("archive does not contain %q at its root", member) + } + return nil +} + +func writeExtractedFile(dest string, r io.Reader) error { + f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o755) //nolint:gosec // G302: it's the executable being installed + if err != nil { + return err + } + n, err := io.Copy(f, io.LimitReader(r, maxBinaryBytes+1)) + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + if n > maxBinaryBytes { + return fmt.Errorf("extracted binary exceeds %d bytes — refusing", maxBinaryBytes) + } + return nil +} + +// replaceExecutable swaps staged into place at target, returning the path of +// the preserved previous binary. The target path is continuously occupied on +// unix: the old inode is preserved via hard link (or a synced copy when the +// filesystem refuses links) and a single rename-over installs the new binary. +// On Windows a loaded exe cannot be renamed over, so the running exe moves to +// `.old` first; a failed second rename rolls that back. +func replaceExecutable(goos, target, staged string) (string, error) { + if goos == "windows" { + backup := target + ".old" + // A leftover .old from an earlier upgrade could be a crash's recovery + // artifact — preserve it under a unique name rather than deleting it. + if _, statErr := os.Lstat(backup); statErr == nil { + _ = renameFile(backup, target+".recovered-"+upgradeRandSuffix()) + } + if err := renameFile(target, backup); err != nil { + return "", fmt.Errorf("move current binary aside: %w", err) + } + if err := renameFile(staged, target); err != nil { + if restoreErr := renameFile(backup, target); restoreErr != nil { + return "", &swapCatastropheError{backup: backup, installErr: err, restoreErr: restoreErr} + } + return "", fmt.Errorf("install new binary: %w", err) + } + return backup, nil + } + + backup := target + ".old-" + upgradeRandSuffix() + if err := linkFile(target, backup); err != nil { + if copyErr := copyFileSync(target, backup); copyErr != nil { + return "", fmt.Errorf("preserve current binary: %w", copyErr) + } + } + if err := renameFile(staged, target); err != nil { + _ = os.Remove(backup) + return "", fmt.Errorf("install new binary: %w", err) + } + return backup, nil +} + +// swapCatastropheError reports the worst-case swap failure: the running +// executable was moved aside, installing the new binary failed, AND moving +// the original back also failed — the target path may now have no binary at +// all. Callers must surface the preserved backup path instead of claiming +// the existing install was left in place. +type swapCatastropheError struct { + backup string + installErr error + restoreErr error +} + +func (e *swapCatastropheError) Error() string { + return fmt.Sprintf("install new binary: %v (restoring the original also failed: %v)", e.installErr, e.restoreErr) +} + +// swapFailureError maps a replaceExecutable failure to the user-facing error. +// The ordinary case leaves the previous binary installed; the catastrophic +// case must not claim that — it names the preserved backup and how to recover. +func swapFailureError(target string, err error) *output.Error { + var cat *swapCatastropheError + if errors.As(err, &cat) { + return errUpgradeFailedHint( + fmt.Sprintf("could not install the new binary and could not restore the previous one: %v — %s may now be missing its executable", err, target), + fmt.Sprintf("Your previous binary was preserved at %s — move it back to %s, or reinstall: %s", cat.backup, target, installerHint), + ) + } + return errUpgradeFailed(fmt.Sprintf("could not install the new binary: %v", err)) +} + +// restoreBackup puts the preserved previous binary back at target after a +// failed post-install probe. Unix is a single rename-over (target stays +// occupied); Windows moves the bad binary aside first. +func restoreBackup(goos, target, backup string) error { + if goos == "windows" { + aside := filepath.Join(filepath.Dir(target), upgradeStagePrefix+"failed-"+upgradeRandSuffix()+".exe") + if err := renameFile(target, aside); err != nil { + return err + } + if err := renameFile(backup, target); err != nil { + return err + } + _ = os.Remove(aside) + return nil + } + return renameFile(backup, target) +} + +// preserveRecoveryArtifact renames a backup to a unique `.recovered-` +// name once it has become the user's only good binary (rollback failed). +// The unique suffix means a pre-existing recovery artifact from an earlier +// failure is never clobbered. Returns the path to reference — the original +// backup path when even this rename fails, which is still safe: cleanup +// never reaps `.old*` or `.recovered-*` names (only an upgrade disposes of +// its own verified-garbage backup, via discardBackup). +func preserveRecoveryArtifact(target, backup string) string { + recovered := target + ".recovered-" + upgradeRandSuffix() + if err := renameFile(backup, recovered); err != nil { + return backup + } + return recovered +} + +// discardBackup disposes of the pre-upgrade backup after the installed +// binary has been probe-verified — only then is the backup provably garbage +// (a positive safe-to-reap decision; cleanup never infers it). Windows keeps +// the running old exe locked (delete fails, rename succeeds), so the +// fallback renames it into the always-reaped staging namespace for a later +// invocation's cleanup. +func discardBackup(target, backup string) { + if removeFile(backup) == nil { + return + } + _ = renameFile(backup, filepath.Join(filepath.Dir(target), upgradeStagePrefix+"reap-"+upgradeRandSuffix())) +} + +// copyFileSync writes a fully-synced copy of src at dst, preserving src's +// mode. Fallback for filesystems without hard-link support. +func copyFileSync(src, dst string) error { + in, err := os.Open(src) //nolint:gosec // G304: src is the running executable + if err != nil { + return err + } + defer in.Close() + + info, err := in.Stat() + if err != nil { + return err + } + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) + if err != nil { + return err + } + _, err = io.Copy(out, in) + if err == nil { + err = out.Sync() + } + if closeErr := out.Close(); err == nil { + err = closeErr + } + if err != nil { + _ = os.Remove(dst) + return err + } + return nil +} + +// probeBinaryVersion runs `path --version` and returns the reported semantic +// version (the trailing field, "v" stripped). +func probeBinaryVersion(ctx context.Context, path string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, path, "--version").Output() //nolint:gosec // G204: path is the resolved install target or our own staged file + if err != nil { + return "", err + } + fields := strings.Fields(strings.TrimSpace(string(out))) + if len(fields) == 0 { + return "", fmt.Errorf("empty --version output from %s", path) + } + return strings.TrimPrefix(fields[len(fields)-1], "v"), nil +} + +// CleanupUpgradeSidecars removes leftover self-update staging files (the +// `.basecamp-upgrade-*` namespace) next to the running executable. Runs on +// every ordinary CLI invocation so a crashed upgrade's staging files — and +// backups an upgrade explicitly marked safe to reap — don't linger. +func CleanupUpgradeSidecars() { + exe, err := resolveSelfUpdateTarget() + if err != nil { + return + } + cleanupUpgradeSidecarsFor(exe) +} + +func cleanupUpgradeSidecarsFor(exe string) { + // Never reap while the executable itself is missing: after a catastrophic + // swap the `.old` sidecar may be the only remaining binary (this guard + // matters when exe was resolved from somewhere other than the running + // process, e.g. a sibling install invoking cleanup). + if _, err := os.Stat(exe); err != nil { + return + } + + // Skip entirely while an upgrade holds the lock: the staging pattern + // matches that upgrade's live files. Reap happens under the lock; the + // lock file itself is removed last (best-effort — Windows can't delete a + // file with an open handle, so it waits for a later pass). + // + // Only the `.basecamp-upgrade-*` staging namespace is ever reaped — a + // positive safe-to-reap contract: upgrades move a verified-garbage backup + // into this namespace themselves (discardBackup). `.old*` and + // `.recovered-*` names are NEVER reaped here, because after a failed + // rollback they can be the user's only good binary. + lock := flock.New(upgradeLockPath(exe)) + locked, err := lock.TryLock() + if err != nil || !locked { + return + } + + matches, _ := filepath.Glob(filepath.Join(filepath.Dir(exe), upgradeStagePrefix+"*")) + for _, m := range matches { + _ = os.Remove(m) + } + + _ = os.Remove(upgradeLockPath(exe)) + _ = lock.Unlock() +} diff --git a/internal/commands/upgrade_selfupdate_test.go b/internal/commands/upgrade_selfupdate_test.go new file mode 100644 index 000000000..b5d655afa --- /dev/null +++ b/internal/commands/upgrade_selfupdate_test.go @@ -0,0 +1,1329 @@ +package commands + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/gofrs/flock" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/version" +) + +// --- seam stubs --- + +func stubGoInstallChecker(t *testing.T, isGoInstall bool) { + t.Helper() + orig := goInstallChecker + goInstallChecker = func() bool { return isGoInstall } + t.Cleanup(func() { goInstallChecker = orig }) +} + +func stubSelfUpdateTarget(t *testing.T, path string, err error) { + t.Helper() + orig := selfUpdateTargetResolver + selfUpdateTargetResolver = func() (string, error) { return path, err } + t.Cleanup(func() { selfUpdateTargetResolver = orig }) +} + +func stubBinaryVersionProber(t *testing.T, probe func(context.Context, string) (string, error)) { + t.Helper() + orig := binaryVersionProber + binaryVersionProber = probe + t.Cleanup(func() { binaryVersionProber = orig }) +} + +func stubBundleVerifier(t *testing.T, verify func(checksums, bundleBytes []byte, ver string) error) { + t.Helper() + orig := bundleVerifier + bundleVerifier = verify + t.Cleanup(func() { bundleVerifier = orig }) +} + +func stubEuid(t *testing.T, uid int) { + t.Helper() + orig := euidResolver + euidResolver = func() int { return uid } + t.Cleanup(func() { euidResolver = orig }) +} + +func stubHomeDir(t *testing.T, home string) { + t.Helper() + orig := homeDirResolver + homeDirResolver = func() (string, error) { return home, nil } + t.Cleanup(func() { homeDirResolver = orig }) +} + +func stubLinkFile(t *testing.T, link func(oldname, newname string) error) { + t.Helper() + orig := linkFile + linkFile = link + t.Cleanup(func() { linkFile = orig }) +} + +func stubRenameFile(t *testing.T, rename func(oldpath, newpath string) error) { + t.Helper() + orig := renameFile + renameFile = rename + t.Cleanup(func() { renameFile = orig }) +} + +func stubBrewPrefixResolver(t *testing.T, resolve func(context.Context) (string, error)) { + t.Helper() + orig := brewPrefixResolver + brewPrefixResolver = resolve + t.Cleanup(func() { brewPrefixResolver = orig }) +} + +func stubVersion(t *testing.T, v string) { + t.Helper() + orig := version.Version + version.Version = v + t.Cleanup(func() { version.Version = orig }) +} + +// requireUpgradeError asserts the command failed the success/exit contract +// way: structured error with the expected code and a nonzero exit code. +func requireUpgradeError(t *testing.T, err error, code string) *output.Error { + t.Helper() + require.Error(t, err) + apiErr := output.AsError(err) + assert.Equal(t, code, apiErr.Code) + assert.NotZero(t, apiErr.ExitCode()) + return apiErr +} + +// --- archive builders --- + +type tarEntry struct { + name string + body []byte + typeflag byte + linkname string +} + +func buildTarGz(t *testing.T, entries []tarEntry) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, e := range entries { + typeflag := e.typeflag + if typeflag == 0 { + typeflag = tar.TypeReg + } + hdr := &tar.Header{ + Name: e.name, + Mode: 0o755, + Size: int64(len(e.body)), + Typeflag: typeflag, + Linkname: e.linkname, + } + require.NoError(t, tw.WriteHeader(hdr)) + if typeflag == tar.TypeReg { + _, err := tw.Write(e.body) + require.NoError(t, err) + } + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return buf.Bytes() +} + +type zipEntry struct { + name string + body []byte + symlink bool +} + +func buildZip(t *testing.T, entries []zipEntry) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, e := range entries { + hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate} + if e.symlink { + hdr.SetMode(os.ModeSymlink | 0o777) + } else { + hdr.SetMode(0o755) + } + w, err := zw.CreateHeader(hdr) + require.NoError(t, err) + _, err = w.Write(e.body) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +func writeArchiveFile(t *testing.T, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "archive") + require.NoError(t, os.WriteFile(path, data, 0o600)) + return path +} + +// --- flow fixture --- + +// nativeFlowFixture wires every seam for an end-to-end runUpgrade exercise of +// the native self-update path against an httptest release server. +type nativeFlowFixture struct { + target string // installed fake binary + oldContent []byte + newContent []byte + latest string + server *httptest.Server + hits atomic.Int32 + release releaseInfo +} + +func setupNativeFlow(t *testing.T, current, latest string) *nativeFlowFixture { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("native flow fixture builds tar.gz archives (unix asset shape)") + } + + f := &nativeFlowFixture{ + latest: latest, + oldContent: []byte("old-binary-" + current), + newContent: []byte("new-binary-" + latest), + } + + stubVersion(t, current) + stubGoInstallChecker(t, false) + stubEuid(t, 1000) + + home := t.TempDir() + binDir := filepath.Join(home, "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + f.target = filepath.Join(binDir, "basecamp") + require.NoError(t, os.WriteFile(f.target, f.oldContent, 0o755)) + stubHomeDir(t, home) + stubSelfUpdateTarget(t, f.target, nil) + + archiveName := fmt.Sprintf("basecamp_%s_%s_%s.tar.gz", latest, runtime.GOOS, runtime.GOARCH) + archive := buildTarGz(t, []tarEntry{{name: "basecamp", body: f.newContent}}) + sum := sha256.Sum256(archive) + checksums := []byte(hex.EncodeToString(sum[:]) + " " + archiveName + "\n") + bundleBytes := []byte("stub-bundle") + + mux := http.NewServeMux() + serve := func(name string, data []byte) { + mux.HandleFunc("/"+name, func(w http.ResponseWriter, r *http.Request) { + f.hits.Add(1) + _, _ = w.Write(data) + }) + } + serve(archiveName, archive) + serve("checksums.txt", checksums) + serve("checksums.txt.bundle", bundleBytes) + f.server = httptest.NewServer(mux) + t.Cleanup(f.server.Close) + + f.release = releaseInfo{ + Version: latest, + Assets: []releaseAsset{ + {Name: archiveName, DownloadURL: f.server.URL + "/" + archiveName}, + {Name: "checksums.txt", DownloadURL: f.server.URL + "/checksums.txt"}, + {Name: "checksums.txt.bundle", DownloadURL: f.server.URL + "/checksums.txt.bundle"}, + }, + } + stubUpgradeCheckers(t, upgradeCheckersStub{release: &f.release}) + + stubBundleVerifier(t, func(_, _ []byte, _ string) error { return nil }) + + // Staged and installed binaries both report the new version by default. + stubBinaryVersionProber(t, func(_ context.Context, path string) (string, error) { + return latest, nil + }) + + // Confine the flow's MkdirTemp so leftover temp dirs are detectable. + t.Setenv("TMPDIR", t.TempDir()) + + return f +} + +func (f *nativeFlowFixture) upgradeTempLeftovers(t *testing.T) []string { + t.Helper() + matches, err := filepath.Glob(filepath.Join(os.TempDir(), "basecamp-upgrade-*")) + require.NoError(t, err) + return matches +} + +// --- native flow tests --- + +func TestNativeSelfUpdateSuccess(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + // Distinctive mode to prove preservation, and a stale sidecar to prove + // cleanup-adjacent behavior (backup removed on success). + require.NoError(t, os.Chmod(f.target, 0o700)) + + cmdOut, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + + assert.Contains(t, cmdOut, "update available: 1.1.0") + assert.Contains(t, cmdOut, "Upgraded 1.0.0 → 1.1.0") + assert.Contains(t, appBuf.String(), `"upgraded"`) + assert.Contains(t, appBuf.String(), `"native"`) + + installed, readErr := os.ReadFile(f.target) + require.NoError(t, readErr) + assert.Equal(t, f.newContent, installed) + + info, statErr := os.Stat(f.target) + require.NoError(t, statErr) + assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + + // No staging or backup sidecars remain next to the binary. + leftovers, globErr := filepath.Glob(filepath.Join(filepath.Dir(f.target), ".basecamp*")) + require.NoError(t, globErr) + assert.Empty(t, leftovers) + assert.Empty(t, f.upgradeTempLeftovers(t)) +} + +func TestNativeSelfUpdateChecksumMismatch(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + // Corrupt the served checksums: right shape, wrong hash. + archiveName := f.release.Assets[0].Name + bad := strings.Repeat("ab", 32) + " " + archiveName + "\n" + f.release.Assets[1].DownloadURL = serveBytes(t, []byte(bad)) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + assert.Contains(t, apiErr.Message, "checksum mismatch") + + assertTargetUntouched(t, f) +} + +func TestNativeSelfUpdateChecksumEntryMissing(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + f.release.Assets[1].DownloadURL = serveBytes(t, []byte("deadbeef some_other_file.tar.gz\n")) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + assert.Contains(t, apiErr.Message, "no entry") + + assertTargetUntouched(t, f) +} + +func TestNativeSelfUpdateMissingBundleAsset(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + f.release.Assets = f.release.Assets[:2] // drop checksums.txt.bundle + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + assert.Contains(t, apiErr.Message, "checksums.txt.bundle") + + assertTargetUntouched(t, f) +} + +func TestNativeSelfUpdateBundleVerificationFailure(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + stubBundleVerifier(t, func(_, _ []byte, _ string) error { + return errors.New("no matching certificate identity") + }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + assert.Contains(t, apiErr.Message, "signature verification failed") + + assertTargetUntouched(t, f) +} + +func TestNativeSelfUpdateNoPlatformAsset(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + f.release.Assets = f.release.Assets[1:] // drop the platform archive + + cmdOut, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_required") + assert.Contains(t, apiErr.Message, "no prebuilt binary") + assert.Contains(t, apiErr.Hint, "releases/tag/v1.1.0") + assert.Contains(t, cmdOut, "releases/tag/v1.1.0") + + assertTargetUntouched(t, f) +} + +func TestNativeSelfUpdatePostVerifyMismatchRestoresOldBinary(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + // The staged pre-install probe passes; the installed binary then reports + // the old version — the swap must be rolled back. + stubBinaryVersionProber(t, func(_ context.Context, path string) (string, error) { + if strings.Contains(filepath.Base(path), upgradeStagePrefix) { + return f.latest, nil + } + return "1.0.0", nil + }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + assert.Contains(t, apiErr.Message, "was restored") + + installed, readErr := os.ReadFile(f.target) + require.NoError(t, readErr) + assert.Equal(t, f.oldContent, installed) +} + +func TestNativeSelfUpdatePreProbeFailureLeavesTarget(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + stubBinaryVersionProber(t, func(_ context.Context, path string) (string, error) { + if strings.Contains(filepath.Base(path), upgradeStagePrefix) { + return "", errors.New("exec format error") + } + return f.latest, nil + }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + assert.Contains(t, apiErr.Message, "pre-install check") + + assertTargetUntouched(t, f) +} + +// assertTargetUntouched verifies the installed binary and its directory +// survived a failed upgrade unchanged, and no temp dirs leaked. +func assertTargetUntouched(t *testing.T, f *nativeFlowFixture) { + t.Helper() + installed, err := os.ReadFile(f.target) + require.NoError(t, err) + assert.Equal(t, f.oldContent, installed) + + leftovers, err := filepath.Glob(filepath.Join(filepath.Dir(f.target), ".basecamp*")) + require.NoError(t, err) + assert.Empty(t, leftovers) + assert.Empty(t, f.upgradeTempLeftovers(t)) +} + +// serveBytes spins up a one-asset server and returns its URL. +func serveBytes(t *testing.T, data []byte) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(data) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// --- real-bundle authenticity (hermetic: vendored fixtures, no network) --- + +func loadSelfUpdateFixtures(t *testing.T) (trusted root.TrustedMaterial, checksums, bundleBytes []byte) { + t.Helper() + trustedRoot, err := root.NewTrustedRootFromPath(filepath.Join("testdata", "selfupdate", "trusted_root.json")) + require.NoError(t, err) + checksums, err = os.ReadFile(filepath.Join("testdata", "selfupdate", "checksums.txt")) + require.NoError(t, err) + bundleBytes, err = os.ReadFile(filepath.Join("testdata", "selfupdate", "checksums.txt.bundle")) + require.NoError(t, err) + return trustedRoot, checksums, bundleBytes +} + +func TestVerifyBundleRealReleaseFixtures(t *testing.T) { + trusted, checksums, bundleBytes := loadSelfUpdateFixtures(t) + + // The published v0.8.1 bundle verifies under the release-tag identity. + require.NoError(t, verifyBundleWithRoot(trusted, checksums, bundleBytes, "0.8.1")) +} + +func TestVerifyBundleRejectsWrongIdentity(t *testing.T) { + trusted, checksums, bundleBytes := loadSelfUpdateFixtures(t) + + err := verifyBundleWithRoot(trusted, checksums, bundleBytes, "9.9.9") + require.Error(t, err) +} + +func TestVerifyBundleRejectsTamperedArtifact(t *testing.T) { + trusted, checksums, bundleBytes := loadSelfUpdateFixtures(t) + + tampered := append([]byte("tampered\n"), checksums...) + err := verifyBundleWithRoot(trusted, tampered, bundleBytes, "0.8.1") + require.Error(t, err) +} + +func TestVerifyBundleRejectsGarbageBundle(t *testing.T) { + trusted, checksums, _ := loadSelfUpdateFixtures(t) + + err := verifyBundleWithRoot(trusted, checksums, []byte("not a bundle"), "0.8.1") + require.Error(t, err) +} + +// --- path policy --- + +func TestSelfUpdateIneligibleAsRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("root check is unix-only") + } + stubEuid(t, 0) + reason, hint := selfUpdateIneligibility("/home/user/bin/basecamp") + assert.Equal(t, "running_as_root", reason) + assert.NotEmpty(t, hint) +} + +func TestSelfUpdateIneligibleNixStore(t *testing.T) { + stubEuid(t, 1000) + reason, hint := selfUpdateIneligibility("/nix/store/abc123-basecamp-cli/bin/basecamp") + assert.Equal(t, "nix_store", reason) + assert.Contains(t, hint, "Nix") +} + +func TestSelfUpdateIneligibleSiblingPrefixHomeEscape(t *testing.T) { + stubEuid(t, 1000) + base := t.TempDir() + home := filepath.Join(base, "jeremy") + sibling := filepath.Join(base, "jeremyx", "bin") + require.NoError(t, os.MkdirAll(home, 0o755)) + require.NoError(t, os.MkdirAll(sibling, 0o755)) + target := filepath.Join(sibling, "basecamp") + require.NoError(t, os.WriteFile(target, []byte("x"), 0o755)) + stubHomeDir(t, home) + + reason, _ := selfUpdateIneligibility(target) + assert.Equal(t, "system_path", reason) +} + +func TestSelfUpdateIneligibleSymlinkEscape(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation not reliable on windows CI") + } + stubEuid(t, 1000) + base := t.TempDir() + home := filepath.Join(base, "home") + outside := filepath.Join(base, "outside") + require.NoError(t, os.MkdirAll(home, 0o755)) + require.NoError(t, os.MkdirAll(outside, 0o755)) + // ~/bin is a symlink pointing outside the home directory. + require.NoError(t, os.Symlink(outside, filepath.Join(home, "bin"))) + target := filepath.Join(home, "bin", "basecamp") + require.NoError(t, os.WriteFile(target, []byte("x"), 0o755)) + stubHomeDir(t, home) + + reason, _ := selfUpdateIneligibility(target) + assert.Equal(t, "system_path", reason) +} + +func TestSelfUpdateIneligibleUnwritableDir(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("permission bits not enforceable here") + } + stubEuid(t, 1000) + home := t.TempDir() + binDir := filepath.Join(home, "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + target := filepath.Join(binDir, "basecamp") + require.NoError(t, os.WriteFile(target, []byte("x"), 0o755)) + require.NoError(t, os.Chmod(binDir, 0o555)) + t.Cleanup(func() { _ = os.Chmod(binDir, 0o755) }) + stubHomeDir(t, home) + + reason, _ := selfUpdateIneligibility(target) + assert.Equal(t, "not_writable", reason) +} + +func TestSelfUpdateEligibleUnderHome(t *testing.T) { + stubEuid(t, 1000) + home := t.TempDir() + binDir := filepath.Join(home, ".local", "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + target := filepath.Join(binDir, "basecamp") + require.NoError(t, os.WriteFile(target, []byte("x"), 0o755)) + stubHomeDir(t, home) + + reason, hint := selfUpdateIneligibility(target) + assert.Empty(t, reason) + assert.Empty(t, hint) +} + +func TestUpgradePolicyFailureSkipsDownload(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + stubEuid(t, 0) // policy fails before any download + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_required") + assert.Contains(t, apiErr.Message, "running_as_root") + assert.Zero(t, f.hits.Load()) +} + +func TestUpgradeGoInstallProvenance(t *testing.T) { + for _, current := range []string{"1.0.0", "0.4.1-0.20260313174735-243815fa23b2"} { + t.Run(current, func(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, current) + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.1.0"}) + stubGoInstallChecker(t, true) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_required") + assert.Contains(t, apiErr.Message, "go install") + assert.Contains(t, apiErr.Hint, "go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest") + }) + } +} + +func TestUpgradeUnresolvableExecutable(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.0.0") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.1.0"}) + stubGoInstallChecker(t, false) + stubSelfUpdateTarget(t, "", errors.New("no exe")) + + cmdOut, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_required") + assert.Contains(t, apiErr.Message, "could not be resolved") + assert.Contains(t, cmdOut, "releases/tag/v1.1.0") +} + +// --- swap contract --- + +func TestReplaceExecutableUnixPreservesInodeViaHardLink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix swap semantics") + } + dir := t.TempDir() + target := filepath.Join(dir, "basecamp") + staged := filepath.Join(dir, ".basecamp-upgrade-test") + require.NoError(t, os.WriteFile(target, []byte("old"), 0o755)) + require.NoError(t, os.WriteFile(staged, []byte("new"), 0o755)) + + backup, err := replaceExecutable(runtime.GOOS, target, staged) + require.NoError(t, err) + + installed, _ := os.ReadFile(target) + assert.Equal(t, []byte("new"), installed) + preserved, readErr := os.ReadFile(backup) + require.NoError(t, readErr) + assert.Equal(t, []byte("old"), preserved) + + // Rollback restores the preserved inode via a single rename-over. + require.NoError(t, restoreBackup(runtime.GOOS, target, backup)) + restored, _ := os.ReadFile(target) + assert.Equal(t, []byte("old"), restored) +} + +func TestReplaceExecutableUnixHardLinkFallbackCopies(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix swap semantics") + } + stubLinkFile(t, func(_, _ string) error { return errors.New("EPERM: links unsupported") }) + + dir := t.TempDir() + target := filepath.Join(dir, "basecamp") + staged := filepath.Join(dir, ".basecamp-upgrade-test") + require.NoError(t, os.WriteFile(target, []byte("old"), 0o755)) + require.NoError(t, os.WriteFile(staged, []byte("new"), 0o755)) + + backup, err := replaceExecutable(runtime.GOOS, target, staged) + require.NoError(t, err) + + preserved, readErr := os.ReadFile(backup) + require.NoError(t, readErr) + assert.Equal(t, []byte("old"), preserved) + installed, _ := os.ReadFile(target) + assert.Equal(t, []byte("new"), installed) +} + +func TestReplaceExecutableUnixBackupFailureLeavesTarget(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("permission bits not enforceable here") + } + stubLinkFile(t, func(_, _ string) error { return errors.New("EPERM") }) + + dir := t.TempDir() + target := filepath.Join(dir, "basecamp") + staged := filepath.Join(dir, ".basecamp-upgrade-test") + require.NoError(t, os.WriteFile(target, []byte("old"), 0o755)) + require.NoError(t, os.WriteFile(staged, []byte("new"), 0o755)) + require.NoError(t, os.Chmod(dir, 0o555)) // backup copy creation fails + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + _, err := replaceExecutable(runtime.GOOS, target, staged) + require.Error(t, err) + assert.Contains(t, err.Error(), "preserve current binary") + + installed, _ := os.ReadFile(target) + assert.Equal(t, []byte("old"), installed) +} + +func TestReplaceExecutableUnixRenameFailureKeepsOldBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix swap semantics") + } + dir := t.TempDir() + target := filepath.Join(dir, "basecamp") + staged := filepath.Join(dir, ".basecamp-upgrade-missing") // never created + require.NoError(t, os.WriteFile(target, []byte("old"), 0o755)) + + _, err := replaceExecutable(runtime.GOOS, target, staged) + require.Error(t, err) + assert.Contains(t, err.Error(), "install new binary") + + installed, _ := os.ReadFile(target) + assert.Equal(t, []byte("old"), installed) + + // The failed attempt must not strand its backup sidecar. + leftovers, _ := filepath.Glob(target + ".old-*") + assert.Empty(t, leftovers) +} + +func TestReplaceExecutableWindowsShuffle(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "basecamp.exe") + staged := filepath.Join(dir, ".basecamp-upgrade-test.exe") + require.NoError(t, os.WriteFile(target, []byte("old"), 0o755)) + require.NoError(t, os.WriteFile(staged, []byte("new"), 0o755)) + + backup, err := replaceExecutable("windows", target, staged) + require.NoError(t, err) + assert.Equal(t, target+".old", backup) + + installed, _ := os.ReadFile(target) + assert.Equal(t, []byte("new"), installed) + preserved, _ := os.ReadFile(backup) + assert.Equal(t, []byte("old"), preserved) + + // Post-probe restore: new moved aside, .old back in place. + require.NoError(t, restoreBackup("windows", target, backup)) + restored, _ := os.ReadFile(target) + assert.Equal(t, []byte("old"), restored) +} + +// Both the install rename AND the rollback rename fail — the worst case, +// where the target path is left with no executable. The error must be the +// distinct catastrophe carrying the backup path, never the ordinary +// "install failed, original still in place" shape. +func TestReplaceExecutableWindowsDoubleRenameFailureIsCatastrophic(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "basecamp.exe") + staged := filepath.Join(dir, ".basecamp-upgrade-test.exe") + require.NoError(t, os.WriteFile(target, []byte("old"), 0o755)) + require.NoError(t, os.WriteFile(staged, []byte("new"), 0o755)) + + // First rename (running exe → .old) succeeds; every later rename fails. + calls := 0 + stubRenameFile(t, func(oldpath, newpath string) error { + calls++ + if calls == 1 { + return os.Rename(oldpath, newpath) + } + return errors.New("access denied") + }) + + _, err := replaceExecutable("windows", target, staged) + var cat *swapCatastropheError + require.ErrorAs(t, err, &cat) + assert.Equal(t, target+".old", cat.backup) + + // The reported condition is real: the target path has no executable. + _, statErr := os.Stat(target) + assert.True(t, os.IsNotExist(statErr)) + + // And the caller-facing mapping surfaces the backup path and never claims + // the previous binary is still installed. + apiErr := swapFailureError(target, err) + assert.Equal(t, "upgrade_failed", apiErr.Code) + assert.Contains(t, apiErr.Message, "may now be missing") + assert.Contains(t, apiErr.Hint, cat.backup) + assert.NotContains(t, apiErr.Hint, "left in place") +} + +func TestSwapFailureErrorOrdinaryCaseKeepsExistingInstallClaim(t *testing.T) { + apiErr := swapFailureError("/home/u/bin/basecamp", errors.New("install new binary: disk full")) + assert.Equal(t, "upgrade_failed", apiErr.Code) + assert.Contains(t, apiErr.Hint, "left in place") +} + +func TestReplaceExecutableWindowsSecondRenameFailureRollsBack(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "basecamp.exe") + staged := filepath.Join(dir, ".basecamp-upgrade-missing.exe") // never created + require.NoError(t, os.WriteFile(target, []byte("old"), 0o755)) + + _, err := replaceExecutable("windows", target, staged) + require.Error(t, err) + + // First rename is rolled back: target holds the old binary, no .old left. + installed, readErr := os.ReadFile(target) + require.NoError(t, readErr) + assert.Equal(t, []byte("old"), installed) + _, statErr := os.Stat(target + ".old") + assert.True(t, os.IsNotExist(statErr)) +} + +// A failed update check must honor the structured upgrade contract — a plain +// api_error would read as a generic API failure, not an upgrade outcome. +func TestUpgradeCheckFailureIsStructured(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.0.0") + + orig := releaseFetcher + releaseFetcher = func() (releaseInfo, error) { return releaseInfo{}, errors.New("unexpected status: 403") } + t.Cleanup(func() { releaseFetcher = orig }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + assert.Contains(t, apiErr.Message, "could not check for updates") + assert.Contains(t, apiErr.Hint, "GITHUB_TOKEN") +} + +// When rollback fails, the backup is the user's only good binary. It must be +// moved out of the sidecar-reap namespace (`.recovered`), referenced by the +// failure hint, and survive a subsequent startup cleanup. +func TestPostProbeRestoreFailurePreservesRecoveryArtifact(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + stubBinaryVersionProber(t, func(_ context.Context, path string) (string, error) { + if strings.Contains(filepath.Base(path), upgradeStagePrefix) { + return f.latest, nil + } + return "1.0.0", nil + }) + // The rollback rename (backup → target) fails; every other rename is real. + stubRenameFile(t, func(oldpath, newpath string) error { + if strings.Contains(oldpath, ".old-") && newpath == f.target { + return errors.New("permission denied") + } + return os.Rename(oldpath, newpath) + }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + + matches, globErr := filepath.Glob(f.target + ".recovered-*") + require.NoError(t, globErr) + require.Len(t, matches, 1) + recovered := matches[0] + assert.Contains(t, apiErr.Hint, recovered) + data, readErr := os.ReadFile(recovered) + require.NoError(t, readErr) + assert.Equal(t, f.oldContent, data) + + cleanupUpgradeSidecarsFor(f.target) + _, statErr := os.Stat(recovered) + assert.NoError(t, statErr, "recovery artifact must survive startup sidecar cleanup") +} + +// Worst case within the worst case: the rollback rename fails AND the +// preservation rename fails, leaving the backup at its `.old-*` name. The +// hint must reference that surviving path, and cleanup must leave it alone. +func TestPostProbeRestoreAndPreserveFailureKeepsOldBackupSafe(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + stubBinaryVersionProber(t, func(_ context.Context, path string) (string, error) { + if strings.Contains(filepath.Base(path), upgradeStagePrefix) { + return f.latest, nil + } + return "1.0.0", nil + }) + // Every rename OF the backup fails: rollback (backup → target) and + // preservation (backup → .recovered-*) alike. + stubRenameFile(t, func(oldpath, newpath string) error { + if strings.Contains(oldpath, ".old-") { + return errors.New("permission denied") + } + return os.Rename(oldpath, newpath) + }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + + backups, globErr := filepath.Glob(f.target + ".old-*") + require.NoError(t, globErr) + require.Len(t, backups, 1) + assert.Contains(t, apiErr.Hint, backups[0]) + + cleanupUpgradeSidecarsFor(f.target) + data, readErr := os.ReadFile(backups[0]) + require.NoError(t, readErr) + assert.Equal(t, f.oldContent, data, ".old-* backup must survive cleanup — it is the only good binary") +} + +// After a catastrophic swap the `.old` sidecar may be the only binary left — +// cleanup must not reap anything when the executable itself is missing. +func TestCleanupSkipsWhenExecutableMissing(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "basecamp") + backup := exe + ".old" + require.NoError(t, os.WriteFile(backup, []byte("only-copy"), 0o755)) + + cleanupUpgradeSidecarsFor(exe) + + _, err := os.Stat(backup) + assert.NoError(t, err) +} + +// A second upgrade must refuse before any asset download or filesystem +// mutation while another upgrade holds the lock. +func TestUpgradeRefusesWhenLockHeld(t *testing.T) { + app, _ := setupPeopleTestApp(t) + f := setupNativeFlow(t, "1.0.0", "1.1.0") + + lock := flock.New(upgradeLockPath(f.target)) + locked, err := lock.TryLock() + require.NoError(t, err) + require.True(t, locked) + t.Cleanup(func() { _ = lock.Unlock() }) + + _, execErr := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, execErr, "upgrade_failed") + assert.Contains(t, apiErr.Message, "another basecamp upgrade") + + assert.Zero(t, f.hits.Load(), "no downloads may start while the lock is held") + installed, readErr := os.ReadFile(f.target) + require.NoError(t, readErr) + assert.Equal(t, f.oldContent, installed) +} + +// Startup sidecar cleanup must skip entirely while an upgrade is in flight — +// the glob patterns match that upgrade's live staging and backup files. +func TestCleanupSkipsWhileUpgradeLockHeld(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "basecamp") + require.NoError(t, os.WriteFile(exe, []byte("bin"), 0o755)) + sidecars := []string{ + filepath.Join(dir, ".basecamp-upgrade-live"), + exe + ".old-aaaa", + } + for _, s := range sidecars { + require.NoError(t, os.WriteFile(s, []byte("live"), 0o644)) + } + + lock := flock.New(upgradeLockPath(exe)) + locked, err := lock.TryLock() + require.NoError(t, err) + require.True(t, locked) + + cleanupUpgradeSidecarsFor(exe) + for _, s := range sidecars { + _, statErr := os.Stat(s) + assert.NoError(t, statErr, "%s must survive cleanup while the upgrade lock is held", s) + } + + require.NoError(t, lock.Unlock()) + cleanupUpgradeSidecarsFor(exe) + _, statErr := os.Stat(sidecars[0]) + assert.True(t, os.IsNotExist(statErr), "staging file is reaped after the lock is released") + _, statErr = os.Stat(sidecars[1]) + assert.NoError(t, statErr, ".old-* backups are never reaped by cleanup") + _, statErr = os.Stat(upgradeLockPath(exe)) + assert.True(t, os.IsNotExist(statErr), "lock file itself is reaped once no upgrade holds it") +} + +func TestCleanupUpgradeSidecars(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "basecamp") + require.NoError(t, os.WriteFile(exe, []byte("bin"), 0o755)) + + // Only the staging namespace is reaped — a positive safe-to-reap + // contract (discardBackup moves verified-garbage backups into it). + reaped := []string{ + filepath.Join(dir, ".basecamp-upgrade-abc123"), + filepath.Join(dir, ".basecamp-upgrade-failed-def.exe"), + filepath.Join(dir, ".basecamp-upgrade-probe-42"), + filepath.Join(dir, ".basecamp-upgrade-reap-99"), + } + // `.old*` and `.recovered-*` can be the only good binary after a failed + // rollback — never reaped, along with unrelated bystanders. + kept := []string{ + exe + ".old", + exe + ".old-1a2b3c4d", + exe + ".recovered-cafe", + filepath.Join(dir, "basecamp.bak"), + } + for _, s := range append(append([]string{}, reaped...), kept...) { + require.NoError(t, os.WriteFile(s, []byte("leftover"), 0o644)) + } + + cleanupUpgradeSidecarsFor(exe) + + for _, s := range reaped { + _, err := os.Stat(s) + assert.True(t, os.IsNotExist(err), "expected %s to be reaped", s) + } + for _, s := range append(kept, exe) { + _, err := os.Stat(s) + assert.NoError(t, err, "expected %s to survive", s) + } +} + +// A verified upgrade discards its backup; when deletion is impossible (the +// Windows-locked old exe), the backup is renamed into the staging namespace +// so cleanup reaps it later — the positive safe-to-reap marker. +func TestDiscardBackupFallsBackToReapNamespace(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "basecamp") + backup := target + ".old" + require.NoError(t, os.WriteFile(target, []byte("new"), 0o755)) + require.NoError(t, os.WriteFile(backup, []byte("old"), 0o755)) + + orig := removeFile + removeFile = func(string) error { return errors.New("locked") } + t.Cleanup(func() { removeFile = orig }) + + discardBackup(target, backup) + + _, err := os.Stat(backup) + assert.True(t, os.IsNotExist(err), "backup must be moved out of the .old name") + marked, globErr := filepath.Glob(filepath.Join(dir, ".basecamp-upgrade-reap-*")) + require.NoError(t, globErr) + require.Len(t, marked, 1) + + cleanupUpgradeSidecarsFor(target) + _, err = os.Stat(marked[0]) + assert.True(t, os.IsNotExist(err), "reap-marked backup is cleaned up") +} + +// Recovery artifacts get unique names: a pre-existing artifact from an +// earlier failure is never clobbered, and repeated failures all survive. +func TestPreserveRecoveryArtifactIsUniqueAndNonClobbering(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "basecamp") + preexisting := target + ".recovered-aaaa" + require.NoError(t, os.WriteFile(preexisting, []byte("earlier-recovery"), 0o755)) + + backup1 := target + ".old-1111" + backup2 := target + ".old-2222" + require.NoError(t, os.WriteFile(backup1, []byte("first"), 0o755)) + require.NoError(t, os.WriteFile(backup2, []byte("second"), 0o755)) + + kept1 := preserveRecoveryArtifact(target, backup1) + kept2 := preserveRecoveryArtifact(target, backup2) + + assert.NotEqual(t, kept1, kept2) + assert.NotEqual(t, preexisting, kept1) + assert.NotEqual(t, preexisting, kept2) + + for path, want := range map[string]string{preexisting: "earlier-recovery", kept1: "first", kept2: "second"} { + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, want, string(data)) + } +} + +// --- parseChecksums / extraction --- + +func TestParseChecksums(t *testing.T) { + data := "abc123 basecamp_1.0.0_linux_amd64.tar.gz\r\n" + + "DEF456 *basecamp_1.0.0_windows_amd64.zip\r\n" + + "malformed line with too many fields here\n" + + "loner\n" + + "\n" + + "789abc basecamp_1.0.0_darwin_arm64.tar.gz" + + sums := parseChecksums([]byte(data)) + assert.Equal(t, "abc123", sums["basecamp_1.0.0_linux_amd64.tar.gz"]) + assert.Equal(t, "def456", sums["basecamp_1.0.0_windows_amd64.zip"], "star prefix stripped, hash lowercased") + assert.Equal(t, "789abc", sums["basecamp_1.0.0_darwin_arm64.tar.gz"], "no trailing newline") + assert.Len(t, sums, 3) +} + +func TestExtractTarGzMember(t *testing.T) { + archive := writeArchiveFile(t, buildTarGz(t, []tarEntry{ + {name: "README.md", body: []byte("docs")}, + {name: "completions", typeflag: tar.TypeDir}, + {name: "basecamp", body: []byte("the-binary")}, + })) + dest := filepath.Join(t.TempDir(), "out") + + require.NoError(t, extractTarGzMember(archive, "basecamp", dest)) + data, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, []byte("the-binary"), data) +} + +func TestExtractTarGzRejectsNestedOnlyMember(t *testing.T) { + archive := writeArchiveFile(t, buildTarGz(t, []tarEntry{ + {name: "subdir/basecamp", body: []byte("nested")}, + })) + err := extractTarGzMember(archive, "basecamp", filepath.Join(t.TempDir(), "out")) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not contain") +} + +func TestExtractTarGzRejectsMissingMember(t *testing.T) { + archive := writeArchiveFile(t, buildTarGz(t, []tarEntry{ + {name: "README.md", body: []byte("docs")}, + })) + err := extractTarGzMember(archive, "basecamp", filepath.Join(t.TempDir(), "out")) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not contain") +} + +func TestExtractTarGzRejectsDuplicateMembers(t *testing.T) { + archive := writeArchiveFile(t, buildTarGz(t, []tarEntry{ + {name: "basecamp", body: []byte("one")}, + {name: "basecamp", body: []byte("two")}, + })) + err := extractTarGzMember(archive, "basecamp", filepath.Join(t.TempDir(), "out")) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate") +} + +func TestExtractTarGzRejectsSymlinkEntries(t *testing.T) { + archive := writeArchiveFile(t, buildTarGz(t, []tarEntry{ + {name: "evil", typeflag: tar.TypeSymlink, linkname: "/etc/passwd"}, + {name: "basecamp", body: []byte("bin")}, + })) + err := extractTarGzMember(archive, "basecamp", filepath.Join(t.TempDir(), "out")) + require.Error(t, err) + assert.Contains(t, err.Error(), "link entry") +} + +func TestExtractTarGzRejectsHardLinkEntries(t *testing.T) { + archive := writeArchiveFile(t, buildTarGz(t, []tarEntry{ + {name: "basecamp", typeflag: tar.TypeLink, linkname: "target"}, + })) + err := extractTarGzMember(archive, "basecamp", filepath.Join(t.TempDir(), "out")) + require.Error(t, err) + assert.Contains(t, err.Error(), "link entry") +} + +func TestExtractTarGzRejectsOversizedMember(t *testing.T) { + origMax := maxBinaryBytes + maxBinaryBytes = 16 + t.Cleanup(func() { maxBinaryBytes = origMax }) + + archive := writeArchiveFile(t, buildTarGz(t, []tarEntry{ + {name: "basecamp", body: bytes.Repeat([]byte("A"), 64)}, + })) + err := extractTarGzMember(archive, "basecamp", filepath.Join(t.TempDir(), "out")) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds") +} + +func TestExtractZipMember(t *testing.T) { + archive := writeArchiveFile(t, buildZip(t, []zipEntry{ + {name: "README.md", body: []byte("docs")}, + {name: "basecamp.exe", body: []byte("the-binary")}, + })) + dest := filepath.Join(t.TempDir(), "out.exe") + + require.NoError(t, extractZipMember(archive, "basecamp.exe", dest)) + data, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, []byte("the-binary"), data) +} + +func TestExtractZipRejectsSymlinkEntries(t *testing.T) { + archive := writeArchiveFile(t, buildZip(t, []zipEntry{ + {name: "evil", body: []byte("/etc/passwd"), symlink: true}, + {name: "basecamp.exe", body: []byte("bin")}, + })) + err := extractZipMember(archive, "basecamp.exe", filepath.Join(t.TempDir(), "out.exe")) + require.Error(t, err) + assert.Contains(t, err.Error(), "symlink entry") +} + +func TestExtractZipRejectsDuplicateMembers(t *testing.T) { + archive := writeArchiveFile(t, buildZip(t, []zipEntry{ + {name: "basecamp.exe", body: []byte("one")}, + {name: "basecamp.exe", body: []byte("two")}, + })) + err := extractZipMember(archive, "basecamp.exe", filepath.Join(t.TempDir(), "out.exe")) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate") +} + +func TestExtractZipRejectsMissingMember(t *testing.T) { + archive := writeArchiveFile(t, buildZip(t, []zipEntry{ + {name: "nested/basecamp.exe", body: []byte("nested")}, + })) + err := extractZipMember(archive, "basecamp.exe", filepath.Join(t.TempDir(), "out.exe")) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not contain") +} + +// --- brew/scoop post-verification --- + +func TestUpgradeHomebrewStaleProbeIsIncomplete(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isBrew: true}) + stubBrewPrefixResolver(t, func(context.Context) (string, error) { return "/opt/homebrew", nil }) + stubBinaryVersionProber(t, func(_ context.Context, path string) (string, error) { + assert.Equal(t, filepath.Join("/opt/homebrew", "bin", "basecamp"), path) + return "1.2.3", nil + }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_incomplete") + assert.Contains(t, apiErr.Message, "still reports 1.2.3") + assert.Contains(t, apiErr.Message, "1.3.0") + assert.Contains(t, apiErr.Hint, "brew reinstall --cask basecamp/tap/basecamp-cli") +} + +func TestUpgradeHomebrewPrefixFailureIsUnverified(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isBrew: true}) + stubBrewPrefixResolver(t, func(context.Context) (string, error) { return "", errors.New("brew not on PATH") }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_unverified") + assert.Contains(t, apiErr.Hint, "basecamp version") +} + +func TestUpgradeHomebrewProbeFailureIsUnverified(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isBrew: true}) + stubBrewPrefixResolver(t, func(context.Context) (string, error) { return "/opt/homebrew", nil }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { + return "", errors.New("exec failed") + }) + + _, err := executeUpgradeCommand(t, app) + requireUpgradeError(t, err, "upgrade_unverified") +} + +// Manager execution failures are upgrade outcomes and must honor the +// structured contract, not surface as generic api_error. +func TestUpgradeHomebrewExecFailureIsStructured(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{ + latestVersion: "1.3.0", + isBrew: true, + homebrewUpgrade: func(context.Context, io.Writer, io.Writer) error { + return errors.New("exit status 1") + }, + }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + assert.Contains(t, apiErr.Message, "brew upgrade failed") + assert.Contains(t, apiErr.Hint, "brew upgrade --cask basecamp/tap/basecamp-cli") +} + +func TestUpgradeScoopExecFailureIsStructured(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{ + latestVersion: "1.3.0", + isScoop: true, + isGlobalScoop: true, + scoopUpgrade: func(context.Context, bool, io.Writer, io.Writer) error { + return errors.New("exit status 1") + }, + }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_failed") + assert.Contains(t, apiErr.Message, "scoop update failed") + assert.Contains(t, apiErr.Hint, "scoop update -g basecamp-cli") +} + +// A release published while the manager runs can install something newer +// than the snapshot fetched at the start — reported >= latest is success. +func TestUpgradeHomebrewNewerReportedVersionIsSuccess(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isBrew: true}) + stubBrewPrefixResolver(t, func(context.Context) (string, error) { return "/opt/homebrew", nil }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "1.3.1", nil }) + + _, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + assert.Contains(t, appBuf.String(), `"upgraded"`) + assert.Contains(t, appBuf.String(), "1.3.1") +} + +// An uninterpretable probe result must fail safely as unconfirmed — never +// claim success or completion either way. +func TestUpgradeHomebrewGarbageReportedVersionIsUnverified(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isBrew: true}) + stubBrewPrefixResolver(t, func(context.Context) (string, error) { return "/opt/homebrew", nil }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "source)", nil }) + + _, err := executeUpgradeCommand(t, app) + apiErr := requireUpgradeError(t, err, "upgrade_unverified") + assert.Contains(t, apiErr.Message, "could not be interpreted") +} + +func TestUpgradeHomebrewConfirmedSuccess(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isBrew: true}) + stubBrewPrefixResolver(t, func(context.Context) (string, error) { return "/opt/homebrew", nil }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "1.3.0", nil }) + + _, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + assert.Contains(t, appBuf.String(), `"upgraded"`) + assert.Contains(t, appBuf.String(), `"homebrew"`) +} + +func TestUpgradeScoopStaleProbeIsIncomplete(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isScoop: true}) + stubScoopPrefixResolver(t, func(_ context.Context, appName string) (string, bool) { + return "c:/users/alice/scoop/apps/basecamp-cli/current", true + }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "1.2.3", nil }) + + _, err := executeUpgradeCommand(t, app) + requireUpgradeError(t, err, "upgrade_incomplete") +} + +func TestUpgradeScoopPrefixFailureIsUnverified(t *testing.T) { + app, _ := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isScoop: true}) + stubScoopPrefixResolver(t, func(context.Context, string) (string, bool) { return "", false }) + + _, err := executeUpgradeCommand(t, app) + requireUpgradeError(t, err, "upgrade_unverified") +} + +func TestUpgradeScoopConfirmedSuccess(t *testing.T) { + app, appBuf := setupPeopleTestApp(t) + stubVersion(t, "1.2.3") + stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isScoop: true}) + stubScoopPrefixResolver(t, func(context.Context, string) (string, bool) { + return "c:/users/alice/scoop/apps/basecamp-cli/current", true + }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "1.3.0", nil }) + + _, err := executeUpgradeCommand(t, app) + require.NoError(t, err) + assert.Contains(t, appBuf.String(), `"upgraded"`) + assert.Contains(t, appBuf.String(), `"scoop"`) +} diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go index 79cda77cd..01b8ff8dd 100644 --- a/internal/commands/upgrade_test.go +++ b/internal/commands/upgrade_test.go @@ -32,6 +32,7 @@ func stubScoopPrefixResolver(t *testing.T, resolve func(context.Context, string) type upgradeCheckersStub struct { latestVersion string + release *releaseInfo // optional richer release (assets); read at call time so tests can mutate isBrew bool hasLegacyCask bool isScoop bool @@ -46,9 +47,23 @@ func stubUpgradeCheckers(t *testing.T, stub upgradeCheckersStub) { t.Helper() origVC := versionChecker - versionChecker = func() (string, error) { return stub.latestVersion, nil } + versionChecker = func() (string, error) { + if stub.release != nil { + return stub.release.Version, nil + } + return stub.latestVersion, nil + } t.Cleanup(func() { versionChecker = origVC }) + origRF := releaseFetcher + releaseFetcher = func() (releaseInfo, error) { + if stub.release != nil { + return *stub.release, nil + } + return releaseInfo{Version: stub.latestVersion}, nil + } + t.Cleanup(func() { releaseFetcher = origRF }) + origHC := homebrewChecker homebrewChecker = func(context.Context) bool { return stub.isBrew } t.Cleanup(func() { homebrewChecker = origHC }) @@ -129,19 +144,24 @@ func TestUpgradeAlreadyCurrent(t *testing.T) { assert.Contains(t, appBuf.String(), "up_to_date") } -func TestUpgradeAvailable(t *testing.T) { - app, appBuf := setupPeopleTestApp(t) +// An available update the CLI can't apply is a failure, not a success with a +// link (#589 lineage: "ok": true + release URL read as a completed upgrade). +func TestUpgradeAvailableButUnappliableFails(t *testing.T) { + app, _ := setupPeopleTestApp(t) orig := version.Version version.Version = "1.2.3" t.Cleanup(func() { version.Version = orig }) stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0"}) + stubGoInstallChecker(t, false) + stubSelfUpdateTarget(t, "", assert.AnError) cmdOut, err := executeUpgradeCommand(t, app) - require.NoError(t, err) + apiErr := requireUpgradeError(t, err, "upgrade_required") assert.Contains(t, cmdOut, "update available: 1.3.0") - assert.Contains(t, appBuf.String(), "releases/tag/v1.3.0") + assert.Contains(t, cmdOut, "releases/tag/v1.3.0") + assert.Contains(t, apiErr.Hint, "releases/tag/v1.3.0") } func TestUpgradeSuppressesOlderLatestRelease(t *testing.T) { @@ -202,16 +222,18 @@ func TestUpgradePrefersRenamedHomebrewCaskOverLegacyMigration(t *testing.T) { t.Cleanup(func() { version.Version = orig }) stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isBrew: true, hasLegacyCask: true}) + stubBrewPrefixResolver(t, func(context.Context) (string, error) { return "/opt/homebrew", nil }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "1.3.0", nil }) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) assert.Contains(t, cmdOut, "Upgrading via Homebrew…") assert.Contains(t, appBuf.String(), "upgraded") - assert.NotContains(t, appBuf.String(), "migration_required") + assert.NotContains(t, appBuf.String(), "migration") } func TestUpgradeLegacyCaskMigrationInstructions(t *testing.T) { - app, appBuf := setupPeopleTestApp(t) + app, _ := setupPeopleTestApp(t) orig := version.Version version.Version = "1.2.3" @@ -220,11 +242,12 @@ func TestUpgradeLegacyCaskMigrationInstructions(t *testing.T) { stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", hasLegacyCask: true}) cmdOut, err := executeUpgradeCommand(t, app) - require.NoError(t, err) + apiErr := requireUpgradeError(t, err, "upgrade_required") assert.Contains(t, cmdOut, "The CLI cask has been renamed. To upgrade, run:") assert.Contains(t, cmdOut, " brew uninstall --cask basecamp/tap/basecamp\n") assert.Contains(t, cmdOut, " brew install --cask basecamp/tap/basecamp-cli\n") - assert.Contains(t, appBuf.String(), "migration_required") + assert.Contains(t, apiErr.Message, "migration required") + assert.Contains(t, apiErr.Hint, "brew uninstall --cask basecamp/tap/basecamp") } func TestUpgradePrefersRenamedScoopAppOverLegacyMigration(t *testing.T) { @@ -235,16 +258,20 @@ func TestUpgradePrefersRenamedScoopAppOverLegacyMigration(t *testing.T) { t.Cleanup(func() { version.Version = orig }) stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", isScoop: true, hasLegacyScoop: true}) + stubScoopPrefixResolver(t, func(context.Context, string) (string, bool) { + return "c:/users/alice/scoop/apps/basecamp-cli/current", true + }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "1.3.0", nil }) cmdOut, err := executeUpgradeCommand(t, app) require.NoError(t, err) assert.Contains(t, cmdOut, "Upgrading via Scoop…") assert.Contains(t, appBuf.String(), "upgraded") - assert.NotContains(t, appBuf.String(), "migration_required") + assert.NotContains(t, appBuf.String(), "migration") } func TestUpgradeLegacyScoopMigrationInstructions(t *testing.T) { - app, appBuf := setupPeopleTestApp(t) + app, _ := setupPeopleTestApp(t) orig := version.Version version.Version = "1.2.3" @@ -253,11 +280,11 @@ func TestUpgradeLegacyScoopMigrationInstructions(t *testing.T) { stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", hasLegacyScoop: true}) cmdOut, err := executeUpgradeCommand(t, app) - require.NoError(t, err) + apiErr := requireUpgradeError(t, err, "upgrade_required") assert.Contains(t, cmdOut, "The CLI Scoop manifest has been renamed. To upgrade, run:") assert.Contains(t, cmdOut, " scoop uninstall basecamp\n") assert.Contains(t, cmdOut, " scoop install basecamp-cli\n") - assert.Contains(t, appBuf.String(), "migration_required") + assert.Contains(t, apiErr.Message, "migration required") } func TestUpgradeGlobalScoopUsesGlobalUpdate(t *testing.T) { @@ -277,6 +304,10 @@ func TestUpgradeGlobalScoopUsesGlobalUpdate(t *testing.T) { return nil }, }) + stubScoopPrefixResolver(t, func(context.Context, string) (string, bool) { + return "c:/programdata/scoop/apps/basecamp-cli/current", true + }) + stubBinaryVersionProber(t, func(context.Context, string) (string, error) { return "1.3.0", nil }) _, err := executeUpgradeCommand(t, app) require.NoError(t, err) @@ -285,7 +316,7 @@ func TestUpgradeGlobalScoopUsesGlobalUpdate(t *testing.T) { } func TestUpgradeGlobalLegacyScoopMigrationInstructions(t *testing.T) { - app, appBuf := setupPeopleTestApp(t) + app, _ := setupPeopleTestApp(t) orig := version.Version version.Version = "1.2.3" @@ -294,11 +325,11 @@ func TestUpgradeGlobalLegacyScoopMigrationInstructions(t *testing.T) { stubUpgradeCheckers(t, upgradeCheckersStub{latestVersion: "1.3.0", hasLegacyScoop: true, isGlobalScoop: true}) cmdOut, err := executeUpgradeCommand(t, app) - require.NoError(t, err) + apiErr := requireUpgradeError(t, err, "upgrade_required") assert.Contains(t, cmdOut, "The CLI Scoop manifest has been renamed. To upgrade, run:") assert.Contains(t, cmdOut, " scoop uninstall -g basecamp\n") assert.Contains(t, cmdOut, " scoop install -g basecamp-cli\n") - assert.Contains(t, appBuf.String(), "migration_required") + assert.Contains(t, apiErr.Hint, "scoop uninstall -g basecamp") } func TestIsHomebrewUsesExecutablePathProvenance(t *testing.T) { diff --git a/internal/version/version.go b/internal/version/version.go index e51e3fada..55e3ff414 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -21,14 +21,26 @@ var ( Date = "unknown" ) +// fromBuildInfo records whether Version was derived from debug.ReadBuildInfo +// rather than stamped via ldflags. Build-info versions come from `go install` +// (stable tags and pseudo-versions alike) and cannot be self-updated in place. +var fromBuildInfo bool + func init() { if Version == "dev" { if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { Version = strings.TrimPrefix(info.Main.Version, "v") + fromBuildInfo = true } } } +// IsGoInstall reports whether this binary's version came from Go module build +// info (a `go install` build) instead of release ldflags. +func IsGoInstall() bool { + return fromBuildInfo +} + //go:embed sdk-provenance.json var sdkProvenanceJSON []byte diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 48e2f9014..79473bbf8 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -104,3 +104,20 @@ func TestGetSDKProvenanceIsCached(t *testing.T) { t.Error("GetSDKProvenance() should return the same pointer on repeated calls") } } + +func TestIsGoInstall(t *testing.T) { + original := fromBuildInfo + defer func() { fromBuildInfo = original }() + + fromBuildInfo = false + if IsGoInstall() { + t.Error("IsGoInstall() = true for ldflags-stamped build, want false") + } + + // Build-info provenance applies to stable versions and pseudo-versions + // alike — the flag, not the version shape, is the signal. + fromBuildInfo = true + if !IsGoInstall() { + t.Error("IsGoInstall() = false for build-info-derived version, want true") + } +} diff --git a/nix/package.nix b/nix/package.nix index 7d77c3976..cda1798c0 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-+j9bY0gSONYBIYZG9uaKgyISp6QUqNl9n8WRZWIPUKI="; + vendorHash = "sha256-GIFzBCedw2vtTYOFZCoB8fByVAtEGLXLjMz83RmPl+Q="; subPackages = [ "cmd/basecamp" ]; diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 28c730b88..5d9c2ef36 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -118,24 +118,58 @@ function Verify-Checksum([string]$ChecksumsPath, [string]$ArchivePath, [string]$ Info 'Checksum verified' } +# Get-CosignBundleSupport decides how to verify the release's Sigstore bundle, +# which is the new (protobuf, v0.3+json) format: +# v3+ -> new-format parsing is the default; no extra flag ('') +# v2.6 - v2.x -> needs '--new-bundle-format=true' (v2.x defaults it to false) +# < v2.6 / unparseable -> $null: cannot verify (v2.4 chokes on the bundle's +# tlog key type, v2.2 lacks the flag); caller warns and skips +function Get-CosignBundleSupport { + try { $versionOutput = & cosign version 2>$null } catch { return $null } + + foreach ($line in @($versionOutput)) { + if ("$line" -match 'GitVersion:\s*v?(\d+)\.(\d+)\.') { + $major = [int]$Matches[1] + $minor = [int]$Matches[2] + if ($major -ge 3) { return '' } + if ($major -eq 2 -and $minor -ge 6) { return '--new-bundle-format=true' } + return $null + } + } + + return $null +} + function Verify-CosignSignature([string]$Version, [string]$BaseUrl, [string]$TmpDir) { if (-not (Get-Command cosign -ErrorAction SilentlyContinue)) { return } + $bundleFlag = Get-CosignBundleSupport + if ($null -eq $bundleFlag) { + Step "Skipping signature verification: this cosign can't verify the release bundle format (need cosign >= 2.6)" + return + } + Step 'Verifying cosign signature...' $bundlePath = Join-Path $TmpDir 'checksums.txt.bundle' $checksumsPath = Join-Path $TmpDir 'checksums.txt' Download-File -Url "$BaseUrl/checksums.txt.bundle" -Destination $bundlePath + $cosignArgs = @('verify-blob', '--bundle', $bundlePath) + if ($bundleFlag) { + $cosignArgs += $bundleFlag + } + $cosignArgs += @( + '--certificate-identity', "https://github.com/basecamp/basecamp-cli/.github/workflows/release.yml@refs/tags/v$Version", + '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', + $checksumsPath + ) + # Native exits don't trigger ErrorActionPreference=Stop on Windows PowerShell 5.1, # so check $LASTEXITCODE explicitly -- otherwise a verify failure would false-green. - & cosign verify-blob ` - --bundle $bundlePath ` - --certificate-identity "https://github.com/basecamp/basecamp-cli/.github/workflows/release.yml@refs/tags/v$Version" ` - --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' ` - $checksumsPath + & cosign @cosignArgs if ($LASTEXITCODE -ne 0) { Fail 'Cosign signature verification failed' } diff --git a/scripts/install.sh b/scripts/install.sh index ab50c977f..729e0ece5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -194,6 +194,41 @@ get_latest_version() { error "Could not determine latest version. ${CURL_LAST_ERROR:+curl said: ${CURL_LAST_ERROR}. }If native Windows curl fails, try Scoop or PowerShell. If using Git Bash, try /usr/bin/curl instead." } +# cosign_version prints the installed cosign's version (e.g. "v2.6.0"), empty +# when it can't be determined. The trailing `|| true` is load-bearing: under +# `set -euo pipefail` a broken cosign (nonzero `cosign version`) would +# otherwise abort the whole installer from inside the caller's command +# substitution — an unusable cosign must degrade to warn-and-skip, not abort. +cosign_version() { + cosign version 2>/dev/null | awk -F': *' '/^GitVersion/ {print $2; exit}' || true +} + +# cosign_bundle_support decides how to verify the release's Sigstore bundle, +# which is the new (protobuf, v0.3+json) format. Prints the extra verify-blob +# flag to use ("" for none) and returns 0 when verification can proceed: +# v3+ → new-format parsing is the default; no flag +# v2.6 – v2.x → needs --new-bundle-format=true (v2.x defaults it to false) +# < v2.6 → cannot verify (v2.4 chokes on the bundle's tlog key type, +# v2.2 lacks the flag entirely); caller warns and skips +cosign_bundle_support() { + local version="$1" major minor + + if [[ "$version" =~ ^v?([0-9]+)\.([0-9]+)\. ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + else + return 1 + fi + + if (( major >= 3 )); then + echo "" + elif (( major == 2 && minor >= 6 )); then + echo "--new-bundle-format=true" + else + return 1 + fi +} + verify_checksums() { local version="$1" local tmp_dir="$2" @@ -214,22 +249,33 @@ verify_checksums() { info "Checksum verified" - # If cosign is available, verify the signature + # If cosign is available and understands the bundle format, verify the signature if command -v cosign &>/dev/null; then - step "Verifying cosign signature..." + local cosign_ver bundle_flag + cosign_ver=$(cosign_version) + if bundle_flag=$(cosign_bundle_support "$cosign_ver"); then + step "Verifying cosign signature..." - if ! curl_run -fsSL "${base_url}/checksums.txt.bundle" -o "${tmp_dir}/checksums.txt.bundle"; then - error "Failed to download checksums.txt.bundle${CURL_LAST_ERROR:+ (${CURL_LAST_ERROR})}" - fi + if ! curl_run -fsSL "${base_url}/checksums.txt.bundle" -o "${tmp_dir}/checksums.txt.bundle"; then + error "Failed to download checksums.txt.bundle${CURL_LAST_ERROR:+ (${CURL_LAST_ERROR})}" + fi - cosign verify-blob \ - --bundle "${tmp_dir}/checksums.txt.bundle" \ - --certificate-identity "https://github.com/basecamp/basecamp-cli/.github/workflows/release.yml@refs/tags/v${version}" \ - --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ - "${tmp_dir}/checksums.txt" \ - || error "Cosign signature verification failed" + local -a cosign_args=(verify-blob --bundle "${tmp_dir}/checksums.txt.bundle") + if [[ -n "$bundle_flag" ]]; then + cosign_args+=("$bundle_flag") + fi + cosign_args+=( \ + --certificate-identity "https://github.com/basecamp/basecamp-cli/.github/workflows/release.yml@refs/tags/v${version}" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${tmp_dir}/checksums.txt") + + cosign "${cosign_args[@]}" \ + || error "Cosign signature verification failed" - info "Signature verified" + info "Signature verified" + else + step "Skipping signature verification: cosign ${cosign_ver:-unknown} can't verify this release's bundle format (need cosign >= 2.6)" + fi fi }