diff --git a/.github/workflows/required.yml b/.github/workflows/required.yml index 4c7335e..206bace 100644 --- a/.github/workflows/required.yml +++ b/.github/workflows/required.yml @@ -207,13 +207,19 @@ jobs: [ "$rc" -eq 0 ] || exit 1 echo "ok - every source file carries the canonical header" - # The cargo fallback in install-resq.sh is not a rare path — it is the - # only one that runs, because crates publishes no resq-cli-v* release for - # resolve_tag to find. It must pin, and the unpinned form must require an - # explicit opt-out. Stubs cargo; compiles nothing, fetches nothing. + # The cargo fallback in install-resq.sh (unsupported hosts, releases + # without a binary for this platform, RESQ_FORCE_CARGO=1) must pin, and + # the unpinned form must require an explicit opt-out. Stubs cargo; + # compiles nothing, fetches nothing. - name: install-resq.sh pins its cargo fallback run: sh tests/installers/pinning.sh + # The release-binary path: highest stable tag, API errors are errors, + # exact-identity provenance, version check, staged install, receipt. + # Stubs curl/gh/cargo and runs each case under env -i with a scratch HOME. + - name: install-resq.sh release path verifies before it installs + run: sh tests/installers/release-path.sh + - name: The pinned crates commits still exist upstream # A commit SHA cannot be repointed, which is the point of pinning — but # it can be mistyped or rebased away, and either way every user's diff --git a/README.md b/README.md index 3f9e931..6e7b802 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Each script can be run on its own without going through the full onboarding flow |---|---|---| | `install.sh` / `install.ps1` | Full onboarding — installs prereqs, clones a repo, sets up dev env + hooks | `curl -fsSL https://get.resq.software \| sh` | | `install-hooks.sh` / `install-hooks.ps1` | Drop the canonical git hooks into any repo. Asks to scaffold `local-pre-push` if `resq` is on PATH | `cd && curl -fsSL https://get.resq.software/hooks.sh \| sh` | -| `install-resq.sh` | Install the `resq` CLI binary from the latest GitHub Release (SHA256-verified). Falls back to `cargo install --git --rev ` when no release asset matches — today that fallback is the only path, since no `resq-cli-v*` release exists yet | `curl -fsSL https://get.resq.software/resq.sh \| sh` | +| `install-resq.sh` | Install the `resq` CLI binary from the highest stable `resq-cli-v*` GitHub Release. Checked against `SHA256SUMS` and, when an authenticated `gh` 2.68+ is present, against its Sigstore build attestation (exact `release.yml@refs/tags/` signer, tagged commit on `master`); a failed check installs nothing. The new binary must report the tag's version before it replaces the old one (kept as `resq.prev`), and an install receipt is written to `${XDG_CONFIG_HOME:-~/.config}/resq/install.json`. Falls back to `cargo install --git --rev ` only for an unsupported host or a release without an asset for it; a GitHub API error stops the install rather than falling back | `curl -fsSL https://get.resq.software/resq.sh \| sh` | Every one of these is served pinned and hash-verified, and each has a version-locked form — `https://get.resq.software/v0.4.3/hooks.sh` and so on. @@ -161,6 +161,14 @@ script has ever implemented it, so it silently did nothing.) `SKIP_RESQ_CLI` and `NO_COLOR` — run `sh install.sh --help` for the current list. +All three `resq` installers (`install.sh`, `install.ps1`, `install-resq.sh`) +honour `RESQ_REQUIRE_PROVENANCE=1`: refuse the binary unless its Sigstore +attestation was actually verified, instead of warning and trusting +`SHA256SUMS` alone when `gh` is missing or older than 2.68. +`install-resq.sh` also takes `RESQ_ALLOW_UNVERIFIED=1` to skip a check that +cannot run (it never overrides one that ran and failed, and setting both is +refused as a contradiction). + --- ## 🚀 Quick Start per Repo @@ -226,6 +234,7 @@ groups run on pull requests; the last runs after a deploy. | hook digests re-fetched from the pinned `crates` commit | a stale pin makes every fresh onboard fail a checksum | | pinned `crates` commits are ancestors of `master` | a rebased-away or mistyped SHA breaks `cargo install --rev` | | `install-resq.sh` pins its cargo fallback | an unpinned build of a moving branch, reached from `curl \| sh` | +| `install-resq.sh` release path (stubbed curl/gh) | an API error read as "no release", the wrong tag picked, a failed attestation or wrong-version binary installed anyway | | Nix and Bun installer digests re-checked against the live URLs | upstream changing what we execute | | `stamp.sh --check` | a generated version or hook digest left unstamped | | `gen-pins` missing-PINS guard is reachable | the guard itself regressing, which has happened | @@ -294,7 +303,7 @@ Precisely what that buys, since it is easy to overclaim: `required.yml` re-fetches all six from the pinned commit on every pull request and fails if the digests in either installer disagree — so a stale pin is a red check rather than a broken onboard. -The hooks delegate logic back to the `resq` binary (`resq pre-commit`, etc.), so updates roll out via `install-resq.sh` without editing every repo. That installer prefers a digest-verified release asset and otherwise builds from a pinned commit (`cargo install --git --rev`); an unpinned build of the default branch requires `RESQ_ALLOW_UNVERIFIED=1`. +The hooks delegate logic back to the `resq` binary (`resq pre-commit`, etc.), so updates roll out via `install-resq.sh` without editing every repo. That installer prefers a release asset verified by digest and, where `gh` can check it, by build provenance, and otherwise builds from a pinned commit (`cargo install --git --rev`); an unpinned build of the default branch requires `RESQ_ALLOW_UNVERIFIED=1`. | Hook | What it gates | |---|---| diff --git a/VERSION b/VERSION index ef52a64..f905682 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.6 +0.4.7 diff --git a/install.ps1 b/install.ps1 index da4622e..e077ffc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -33,7 +33,7 @@ $ErrorActionPreference = 'Stop' # GENERATED — stamped from VERSION by bin/stamp.sh. Do not edit by hand: CI # re-runs the stamper and fails if the committed value differs. -$ScriptVersion = '0.4.6' +$ScriptVersion = '0.4.7' $Org = 'resq-software' # Pinned to a version rather than the rolling endpoint, and digest-checked # before it runs — mirrors install.sh. required.yml re-checks the digest against @@ -498,14 +498,93 @@ function Initialize-Repo { } } -# Install the `resq` binary from resq-software/crates GitHub Releases. Chooses -# the archive for the current platform, verifies SHA256 against the release's -# SHA256SUMS, and drops the binary into $env:RESQ_BIN_DIR (default: +# Install the `resq` binary from resq-software/crates GitHub Releases. Picks +# the highest stable resq-cli-vX.Y.Z, chooses the archive for the current +# platform, verifies SHA256 against the release's SHA256SUMS and its Sigstore +# build provenance, and drops the binary into $env:RESQ_BIN_DIR (default: # %LOCALAPPDATA%\Programs\resq\bin on Windows, ~/.local/bin on Unix). # # Idempotent: skips when the currently-installed `resq --version` matches the -# latest release. Skip entirely with $env:SKIP_RESQ_CLI=1. +# latest release. Skip entirely with $env:SKIP_RESQ_CLI=1. With +# $env:RESQ_REQUIRE_PROVENANCE=1, a gh that cannot check provenance means no +# install rather than a warning. +# +# Writes an install receipt to %APPDATA%\resq\install.json on Windows and +# ${XDG_CONFIG_HOME:-~/.config}/resq/install.json elsewhere, same schema as +# scripts/install-resq.sh. function Install-ResqCli { + # Runs gh and returns its exit code and output lines. Windows PowerShell 5.1 + # turns redirected native stderr into error records, which the script-wide + # $ErrorActionPreference = 'Stop' makes terminating; this function's own + # scope lowers it so a gh failure is an exit code to check, not a throw. + # -MergeErr keeps stderr (to show why a check failed); otherwise it is + # dropped so the output is just the value asked for. + function Invoke-GhCapture { + param([string[]]$GhArgs, [switch]$MergeErr) + $ErrorActionPreference = 'Continue' + if (-not (Test-Command 'gh')) { return [pscustomobject]@{ Code = 127; Out = @() } } + if ($MergeErr) { $out = & gh @GhArgs 2>&1 } else { $out = & gh @GhArgs 2>$null } + $code = $LASTEXITCODE + [pscustomobject]@{ Code = $code; Out = @($out | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) } + } + + # The highest resq-cli-vX.Y.Z among $Tags by number, or $null. Anything + # else (another crate's tag, an -rc suffix) is ignored here as well as by + # the API filter, so a pre-release can never be picked. + function Select-ResqCliTag { + param([string[]]$Tags) + $best = $null + $bestVer = $null + foreach ($t in $Tags) { + $t = "$t".Trim() + if ($t -match '^resq-cli-v(\d{1,9})\.(\d{1,9})\.(\d{1,9})$') { + $v = New-Object System.Version -ArgumentList @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3]) + if ($null -eq $bestVer -or $v -gt $bestVer) { $bestVer = $v; $best = $t } + } + } + return $best + } + + # Records what installed the binary and how. A background `resq self + # update` (resq-software/crates) is to replace only a binary whose receipt + # says "method": "release" for that exact path, so a cargo-, nix- or + # hand-built resq is never overwritten behind its owner's back. Written to a + # temp file and renamed into place; a receipt that cannot be written is a + # warning, never a failed install. + function Write-ResqReceipt { + param([string]$Ref, [string]$Path, [string]$Sha256, [string]$Provenance) + $rcDir = $null + $rcTmp = $null + try { + if ($IsNativeWindows) { + $rcDir = Join-Path $env:APPDATA 'resq' + } else { + $rcBase = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME '.config' } + $rcDir = Join-Path $rcBase 'resq' + } + if (-not (Test-Path -LiteralPath $rcDir)) { New-Item -ItemType Directory -Path $rcDir -Force | Out-Null } + $receipt = [ordered]@{ + schema = 1 + method = 'release' + ref = $Ref + path = $Path + sha256 = $Sha256 + provenance = $Provenance + installed_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", [System.Globalization.CultureInfo]::InvariantCulture) + } + $rcTmp = Join-Path $rcDir 'install.json.tmp' + # BOM-less UTF-8: 5.1's Set-Content -Encoding UTF8 writes a BOM, + # which strict JSON readers reject. .NET resolves a relative path + # against its own working directory, not $PWD, hence the full path. + $rcTmp = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($rcTmp) + [System.IO.File]::WriteAllText($rcTmp, ($receipt | ConvertTo-Json) + "`n", (New-Object System.Text.UTF8Encoding $false)) + Move-Item -LiteralPath $rcTmp -Destination (Join-Path $rcDir 'install.json') -Force + } catch { + Write-Warn "Could not write the install receipt in $rcDir ($_)" + if ($rcTmp) { Remove-Item -LiteralPath $rcTmp -Force -ErrorAction SilentlyContinue } + } + } + if ($env:SKIP_RESQ_CLI -eq '1') { Write-Info 'SKIP_RESQ_CLI=1 - skipping resq binary install' return @@ -541,10 +620,18 @@ function Install-ResqCli { return } - # Find latest resq-cli-v* tag. - $tag = gh release list --repo "$Org/crates" --limit 40 --json tagName --jq '.[] | .tagName' 2>$null | - Where-Object { $_ -like 'resq-cli-v*' } | - Select-Object -First 1 + # The highest stable resq-cli-vX.Y.Z by number. This used to take the first + # resq-cli-v* from `gh release list`, which is ordered by creation date; + # crates releases several crates at once, so that was the newest created, + # not the newest version, and could be a pre-release. A failed API call is + # reported as one rather than read as "no release exists". + $listing = Invoke-GhCapture -GhArgs @('api', "repos/$Org/crates/releases?per_page=100", + '--jq', '.[] | select((.prerelease|not) and (.draft|not)) | .tag_name') + if ($listing.Code -ne 0) { + Write-Warn "Could not list $Org/crates releases (gh api exited $($listing.Code)) - skipping binary install" + return + } + $tag = Select-ResqCliTag -Tags $listing.Out if (-not $tag) { Write-Warn "No resq-cli release found in $Org/crates - skipping binary install" return @@ -552,7 +639,11 @@ function Install-ResqCli { $expectedVer = $tag -replace '^resq-cli-v', '' $binDir = if ($env:RESQ_BIN_DIR) { $env:RESQ_BIN_DIR } else { $defaultBinDir } + # Absolute, because File.Replace below resolves a relative path against + # .NET's working directory rather than $PWD, and the receipt records it. + $binDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($binDir) $binPath = Join-Path $binDir $binName + $prevPath = "$binPath.prev" if (Test-Path $binPath) { try { @@ -567,6 +658,7 @@ function Install-ResqCli { Write-Info "Installing resq $expectedVer for $triple..." $tmp = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid())) -Force + $stagePath = $null try { $asset = "resq-cli-${tag}-${triple}.${ext}" gh release download $tag --repo "$Org/crates" --pattern $asset --pattern 'SHA256SUMS' --dir $tmp.FullName --clobber 2>$null @@ -590,6 +682,61 @@ function Install-ResqCli { return } + # Verify build provenance. SHA256SUMS comes from the same release as the + # archive, so it proves only that the download is intact: anyone able + # to publish the release can publish matching sums. The Sigstore + # attestation proves who built it: release.yml in resq-software/crates, + # running at this tag, on a GitHub-hosted runner, from the commit the + # tag names, and that commit is on master. The signer is matched EXACTLY + # (--cert-identity); --signer-workflow would accept any workflow path + # with that prefix. A check that runs and fails means no install, like a + # checksum mismatch; a gh too old to check only warns, unless + # RESQ_REQUIRE_PROVENANCE=1. "Too old" is probed by the flag the check + # uses: gh 2.49-2.67 has `attestation verify` without --source-digest + # (added in 2.68.0), and there every check would fail a good release. + $provenance = 'unchecked' + $attestHelp = Invoke-GhCapture -MergeErr -GhArgs @('attestation', 'verify', '--help') + if ($attestHelp.Code -eq 0 -and ($attestHelp.Out -match '--source-digest')) { + Write-Info 'Verifying build provenance (Sigstore attestation)...' + # gh prints an API error body on stdout, so a failed call must not be + # read as a commit: require exit 0 and a 40-hex SHA. + $shaResult = Invoke-GhCapture -GhArgs @('api', "repos/$Org/crates/commits/$tag", '--jq', '.sha') + $tagSha = $null + if ($shaResult.Code -eq 0) { $tagSha = $shaResult.Out | Select-Object -First 1 } + if (-not ("$tagSha" -match '^[0-9a-f]{40}$')) { + Write-Warn "Could not resolve $tag to a commit to check its provenance - not installing" + return + } + $statusResult = Invoke-GhCapture -GhArgs @('api', "repos/$Org/crates/compare/master...$tagSha", '--jq', '.status') + $tagStatus = $null + if ($statusResult.Code -eq 0) { $tagStatus = $statusResult.Out | Select-Object -First 1 } + if ($tagStatus -notin @('identical', 'behind')) { + Write-Warn "$tag points at $tagSha, which is not on $Org/crates master ($(if ($tagStatus) { $tagStatus } else { 'unknown' })) - not installing a release built from unmerged code" + return + } + $attest = Invoke-GhCapture -MergeErr -GhArgs @('attestation', 'verify', $assetPath, + '--repo', "$Org/crates", + '--cert-identity', "https://github.com/$Org/crates/.github/workflows/release.yml@refs/tags/$tag", + '--cert-oidc-issuer', 'https://token.actions.githubusercontent.com', + '--source-ref', "refs/tags/$tag", + '--source-digest', $tagSha, + '--deny-self-hosted-runners') + if ($attest.Code -ne 0) { + foreach ($line in $attest.Out) { Write-Host " $line" } + Write-Warn "Build provenance verification FAILED for $asset ($tag) - not installing" + return + } + $provenance = 'verified' + Write-Ok "Provenance verified: built by release.yml at $tag ($tagSha)" + } + elseif ($env:RESQ_REQUIRE_PROVENANCE -eq '1') { + Write-Warn "RESQ_REQUIRE_PROVENANCE=1, but this gh cannot check provenance (needs 2.68+) - not installing" + return + } + else { + Write-Warn 'Build provenance NOT checked - needs gh 2.68+. Trusting SHA256SUMS alone.' + } + # Extract. PowerShell ships Expand-Archive for .zip; tar.exe for .tar.gz # is native on Win10+ and on all Unix hosts. if ($ext -eq 'zip') { @@ -609,13 +756,49 @@ function Install-ResqCli { return } + # The new binary has to run and report the version the tag names before + # it may replace anything. It is then staged beside the target and + # swapped in by File.Replace, a rename, so an interrupted install never + # leaves a half-written resq; the binary it replaces is kept as + # $binName.prev. + if (-not $IsNativeWindows -and (Test-Command 'chmod')) { + & chmod 0755 $stagedBin.FullName + } + $newVersion = & { + $ErrorActionPreference = 'Continue' + try { & $stagedBin.FullName --version 2>$null | Select-Object -First 1 } catch { '' } + } + $newVersion = "$newVersion".Trim() + if ($newVersion -notmatch ('\sv?' + [regex]::Escape($expectedVer) + '$')) { + Write-Warn "$asset reports '$(if ($newVersion) { $newVersion } else { 'nothing' })' for --version, not $expectedVer - not installing" + return + } + if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null } - Copy-Item -Path $stagedBin.FullName -Destination $binPath -Force + $stagePath = Join-Path $binDir (".$binName.new." + [System.Guid]::NewGuid().ToString('N').Substring(0, 8)) + Copy-Item -LiteralPath $stagedBin.FullName -Destination $stagePath -Force if (-not $IsNativeWindows -and (Test-Command 'chmod')) { - & chmod 0755 $binPath + & chmod 0755 $stagePath } + try { + if (Test-Path -LiteralPath $binPath) { + Remove-Item -LiteralPath $prevPath -Force -ErrorAction SilentlyContinue + [System.IO.File]::Replace($stagePath, $binPath, $prevPath, $true) + } else { + Move-Item -LiteralPath $stagePath -Destination $binPath + } + } catch { + # On Windows, most likely a resq.exe that is running right now. + Write-Warn "Could not replace $binPath ($($_.Exception.Message)) - not installing" + return + } + $stagePath = $null Write-Ok "Installed $binPath" + $installedSha = '' + try { $installedSha = (Get-FileHash -LiteralPath $binPath -Algorithm SHA256).Hash.ToLower() } catch { $installedSha = '' } + Write-ResqReceipt -Ref $tag -Path $binPath -Sha256 $installedSha -Provenance $provenance + # PATH hint. $pathSep = if ($IsNativeWindows) { ';' } else { ':' } if (-not (($env:PATH -split $pathSep) -contains $binDir)) { @@ -629,6 +812,7 @@ function Install-ResqCli { Install-ResqCompletions -BinPath $binPath } finally { + if ($stagePath) { Remove-Item -LiteralPath $stagePath -Force -ErrorAction SilentlyContinue } Remove-Item -Recurse -Force $tmp.FullName -ErrorAction SilentlyContinue } } diff --git a/install.sh b/install.sh index 48a1822..0fabf99 100755 --- a/install.sh +++ b/install.sh @@ -44,7 +44,7 @@ fi # install.sh keeps verifying against the digest it actually shipped with rather # than against whatever happens to be current. release.yml refuses to publish a # tag whose version disagrees with this value. -SCRIPT_VERSION="0.4.6" +SCRIPT_VERSION="0.4.7" # Everything this script creates belongs to the invoking user alone. Set before # the first mkdir/mktemp so nothing is even briefly group- or world-readable. @@ -575,9 +575,19 @@ post_clone_setup() { # Install the `resq` binary from resq-software/crates GitHub Releases. Chooses # the tar.gz for the current $OS/$ARCH, verifies SHA256 against the release's -# SHA256SUMS, and drops the binary into $RESQ_BIN_DIR (default ~/.local/bin). -# Idempotent: skips when the currently-installed `resq --version` matches the -# latest release. Skip entirely with SKIP_RESQ_CLI=1. +# SHA256SUMS and its build provenance against the Sigstore attestation, and +# drops the binary into $RESQ_BIN_DIR (default ~/.local/bin). Idempotent: skips +# when the currently-installed `resq --version` matches the latest release. +# Skip entirely with SKIP_RESQ_CLI=1. +# +# Every failure here warns and returns without installing rather than aborting +# the onboarding: the resq binary is a convenience, the clone is the point. +# +# Writes an install receipt to ${XDG_CONFIG_HOME:-$HOME/.config}/resq/install.json +# in the same schema as scripts/install-resq.sh. A background `resq self update` +# replaces only a binary whose receipt says "method": "release" for that exact +# path, so a cargo-, nix- or hand-built resq is never overwritten behind its +# owner's back. install_resq_cli() { if [ "${SKIP_RESQ_CLI:-0}" = "1" ]; then info "SKIP_RESQ_CLI=1 — skipping resq binary install" @@ -595,14 +605,25 @@ install_resq_cli() { ;; esac - _tag="$(gh release list --repo "$ORG/crates" --limit 40 \ - --json tagName --jq '.[] | .tagName' 2>/dev/null \ - | grep -m1 '^resq-cli-v' || true)" - if [ -z "$_tag" ]; then - warn "No resq-cli release found in $ORG/crates — skipping binary install" + # The highest stable resq-cli-vX.Y.Z by number. This used to take the first + # resq-cli-v* that `gh release list` printed, but that list is ordered by + # creation date and crates releases several crates at once, so the first + # match was the newest-created, not the newest version — and it could be a + # pre-release. The API call is captured on its own so that a failed request + # (network, auth, rate limit) is reported as that, not as "no release". + if ! _releases="$(gh api "repos/$ORG/crates/releases?per_page=100" \ + --jq '.[] | select((.prerelease | not) and (.draft | not)) | .tag_name' 2>/dev/null)"; then + warn "Could not list releases in $ORG/crates (GitHub API request failed) — skipping binary install" + return 0 + fi + _expected_ver="$(printf '%s\n' "$_releases" \ + | sed -n 's/^resq-cli-v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p' \ + | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1)" + if [ -z "$_expected_ver" ]; then + warn "No stable resq-cli release found in $ORG/crates — skipping binary install" return 0 fi - _expected_ver="$(echo "$_tag" | sed 's/^resq-cli-v//')" + _tag="resq-cli-v$_expected_ver" _bin_dir="${RESQ_BIN_DIR:-$HOME/.local/bin}" _bin_path="$_bin_dir/resq" @@ -644,6 +665,54 @@ install_resq_cli() { return 0 fi + # Verify build provenance. SHA256SUMS comes from the same release as the + # archive, so it proves only that the download is intact — anyone able to + # publish the release can publish matching sums. The Sigstore attestation + # proves who built it: release.yml in $ORG/crates, running at this tag, on a + # GitHub-hosted runner, from the commit the tag names, and that commit is on + # master. The signer is matched EXACTLY (--cert-identity); --signer-workflow + # would accept any workflow path with that prefix. + # + # gh is already authenticated by this point; it needs `attestation verify` + # with --source-ref/--source-digest (2.68+). Probed by the flag, not the + # subcommand: 2.49-2.67 has the subcommand without them, and there every check + # would "fail" and refuse a good release. Without it the install continues on + # the checksum alone and says so; RESQ_REQUIRE_PROVENANCE=1 makes that a + # refusal. A check that RUNS and FAILS always refuses, like a checksum mismatch. + _provenance="unchecked" + if gh attestation verify --help 2>&1 | grep -q -- '--source-digest'; then + info "Verifying build provenance (Sigstore attestation)..." + _tag_sha="$(gh api "repos/$ORG/crates/commits/$_tag" --jq .sha 2>/dev/null)" || _tag_sha="" + if [ -z "$_tag_sha" ]; then + warn "Could not resolve $_tag to a commit to check its provenance — not installing" + return 0 + fi + _tag_status="$(gh api "repos/$ORG/crates/compare/master...$_tag_sha" --jq .status 2>/dev/null)" || _tag_status="" + case "$_tag_status" in + identical|behind) ;; + *) + warn "$_tag points at $_tag_sha, which is not on $ORG/crates master (${_tag_status:-unknown}) — not installing a release built from unmerged code" + return 0 + ;; + esac + if ! _attest_out="$(gh attestation verify "$_tmp/$_asset" --repo "$ORG/crates" \ + --cert-identity "https://github.com/$ORG/crates/.github/workflows/release.yml@refs/tags/$_tag" \ + --cert-oidc-issuer "https://token.actions.githubusercontent.com" \ + --source-ref "refs/tags/$_tag" --source-digest "$_tag_sha" \ + --deny-self-hosted-runners 2>&1)"; then + printf '%s\n' "$_attest_out" >&2 + warn "Build provenance verification FAILED for $_asset ($_tag) — not installing" + return 0 + fi + _provenance="verified" + ok "Provenance verified: built by release.yml at $_tag ($(printf '%.12s' "$_tag_sha"))" + elif [ "${RESQ_REQUIRE_PROVENANCE:-0}" = "1" ]; then + warn "RESQ_REQUIRE_PROVENANCE=1, but this gh cannot check provenance (needs 2.68+) — not installing" + return 0 + else + warn "Build provenance NOT checked — needs gh 2.68+. Trusting SHA256SUMS alone." + fi + tar -xzf "$_tmp/$_asset" -C "$_tmp" # Locate the binary inside the extracted tree rather than assuming the # staging-dir layout. Matches the pattern already used by @@ -655,10 +724,71 @@ install_resq_cli() { return 0 fi + # The new binary has to run and report the version the tag names before it + # may replace anything — an archive holding some other build is caught here, + # not after the working copy is gone. + chmod 0755 "$_staging_bin" + _new_ver="$("$_staging_bin" --version 2>/dev/null | awk 'NR == 1')" || _new_ver="" + case "$_new_ver" in + *" $_expected_ver"|*" v$_expected_ver") ;; + *) + warn "$_asset reports '${_new_ver:-nothing}' for --version, not $_expected_ver — not installing" + return 0 + ;; + esac + + # Staged beside the target and renamed into place, so an interrupted install + # never leaves a half-written resq; the binary it replaces is kept as + # resq.prev. The staging file is registered with the cleanup trap, so an + # interrupt between here and the rename does not leave it behind either + # (after the rename the path no longer exists and cleanup is a no-op). mkdir -p "$_bin_dir" - install -m 0755 "$_staging_bin" "$_bin_path" + if ! _staged="$(mktemp "$_bin_dir/.resq.new.XXXXXX" 2>/dev/null)"; then + warn "Could not create a staging file in $_bin_dir — not installing" + return 0 + fi + _TMP_PATHS="$_TMP_PATHS +$_staged" + if ! install -m 0755 "$_staging_bin" "$_staged"; then + rm -f "$_staged" + warn "Could not stage the new binary in $_bin_dir — not installing" + return 0 + fi + if [ -f "$_bin_path" ]; then + cp -p "$_bin_path" "$_bin_path.prev" \ + || warn "Could not keep the previous binary as $_bin_path.prev" + fi + if ! mv -f "$_staged" "$_bin_path"; then + rm -f "$_staged" + warn "Could not move the new binary into place at $_bin_path — not installing" + return 0 + fi ok "Installed $_bin_path" + # Install receipt, same schema as write_receipt in scripts/install-resq.sh. + # Written to a temp file and renamed, so a reader never sees half a receipt. + # A receipt that cannot be written is a warning, never a failed install. + _rc_dir="${XDG_CONFIG_HOME:-$HOME/.config}/resq" + _rc_sha="$(sha256_of "$_bin_path")" || _rc_sha="" + _rc_path="$(printf '%s' "$_bin_path" | sed 's/\\/\\\\/g; s/"/\\"/g')" + if mkdir -p "$_rc_dir" 2>/dev/null && { + printf '{\n' + printf ' "schema": 1,\n' + printf ' "method": "release",\n' + printf ' "ref": "%s",\n' "$_tag" + printf ' "path": "%s",\n' "$_rc_path" + printf ' "sha256": "%s",\n' "$_rc_sha" + printf ' "provenance": "%s",\n' "$_provenance" + printf ' "installed_at": "%s"\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '}\n' + } > "$_rc_dir/install.json.tmp" 2>/dev/null \ + && mv -f "$_rc_dir/install.json.tmp" "$_rc_dir/install.json"; then + : + else + rm -f "$_rc_dir/install.json.tmp" 2>/dev/null || true + warn "Could not write the install receipt in $_rc_dir" + fi + case ":$PATH:" in *":$_bin_dir:"*) ;; *) warn "$_bin_dir is not in PATH. Add to your shell rc: export PATH=\"$_bin_dir:\$PATH\"" ;; @@ -754,6 +884,9 @@ Environment: RESQ_BIN_DIR=

where to put the resq binary (default: \$HOME/.local/bin) YES=1 assume yes at confirmation prompts SKIP_RESQ_CLI=1 skip installing the resq binary + RESQ_REQUIRE_PROVENANCE=1 + install resq only if its build attestation was verified + (needs gh 2.68+; otherwise SHA256SUMS alone, with a warning) NO_COLOR=1 disable ANSI colour Repositories: diff --git a/scripts/install-resq.sh b/scripts/install-resq.sh index 0c83ce3..1e4d968 100755 --- a/scripts/install-resq.sh +++ b/scripts/install-resq.sh @@ -20,6 +20,16 @@ # RESQ_INSTALL_DIR — install destination (default: $HOME/.local/bin or # $HOME/.cargo/bin if cargo is present) # RESQ_FORCE_CARGO=1 — skip the release-binary path and always cargo-install +# RESQ_REQUIRE_PROVENANCE=1 +# — refuse to install unless the release's Sigstore +# attestation was verified (needs an authenticated gh +# 2.68+); by default a missing gh only warns +# RESQ_ALLOW_UNVERIFIED=1 +# — proceed when the download cannot be verified (no +# SHA256SUMS, no hashing tool, provenance unreachable). +# Never overrides a check that ran and failed. +# +# Writes an install receipt to ${XDG_CONFIG_HOME:-$HOME/.config}/resq/install.json. set -eu @@ -42,6 +52,44 @@ info() { printf 'info %s\n' "$*" >&2; } warn() { printf 'warn %s\n' "$*" >&2; } fail() { printf 'fail %s\n' "$*" >&2; exit 1; } +# sha256 of a file, or nothing when no hashing tool exists (macOS has shasum only). +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + fi +} + +# ── Install receipt ────────────────────────────────────────────────────────── +# +# Records what installed the binary and how, at +# ${XDG_CONFIG_HOME:-$HOME/.config}/resq/install.json. A background +# `resq self update` (resq-software/crates) is to replace only a binary whose +# receipt says "method": "release" for that exact path, so a cargo-, nix- or +# hand-built resq is never overwritten behind its owner's back. A cargo install +# writes "method": "cargo" for the same reason: the release path and cargo's +# default share ~/.cargo/bin, and a stale "release" receipt must not outlive it. +# A receipt that cannot be written is a warning, never a failed install. +json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } +write_receipt() { # + _rc_dir="${XDG_CONFIG_HOME:-$HOME/.config}/resq" + if mkdir -p "$_rc_dir" 2>/dev/null && { + printf '{\n' + printf ' "schema": 1,\n' + printf ' "method": "%s",\n' "$1" + printf ' "ref": "%s",\n' "$(json_str "$2")" + printf ' "path": "%s",\n' "$(json_str "$3")" + printf ' "sha256": "%s",\n' "$4" + printf ' "provenance": "%s",\n' "$5" + printf ' "installed_at": "%s"\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '}\n' + } > "$_rc_dir/install.json.tmp" 2>/dev/null \ + && mv -f "$_rc_dir/install.json.tmp" "$_rc_dir/install.json"; then + : + else + warn "Could not write the install receipt in $_rc_dir." + fi +} + # ── Detect host triple ─────────────────────────────────────────────────────── detect_target() { os=$(uname -s) @@ -58,9 +106,10 @@ TARGET="$(detect_target)" # ── Cargo-install fallback (used by 3 paths below) ─────────────────────────── # -# This is not the rare path. It is, today, the ONLY path: no resq-cli-v* release -# exists, so resolve_tag finds nothing and every run lands here. Until releases -# are published, this function is what `curl .../resq.sh | sh` actually runs. +# Release binaries ship from resq-cli 0.5.0 on, so this now runs only for an +# unsupported host, a release without an asset for this platform, an explicit +# pre-0.5.0 version, or RESQ_FORCE_CARGO=1. It is never reached because the +# GitHub API failed: that stops the install instead (see api_fail below). # # It used to end in `cargo install --git resq-cli` with no --rev and no # --tag, which builds whatever the default branch points at that second. The @@ -87,18 +136,20 @@ cargo_install() { # and quietly building a different one would be worse. info "Installing resq-cli at tag ${TAG_PREFIX}${WANTED_VERSION} via cargo ..." cargo install --git "https://github.com/$REPO" --tag "${TAG_PREFIX}${WANTED_VERSION}" resq-cli - return - fi - - if [ "${RESQ_ALLOW_UNVERIFIED:-0}" = "1" ]; then + _cargo_ref="${TAG_PREFIX}${WANTED_VERSION}" + elif [ "${RESQ_ALLOW_UNVERIFIED:-0}" = "1" ]; then warn "Building resq-cli from the $REPO default branch — unpinned, whatever it points at right now (RESQ_ALLOW_UNVERIFIED=1)." cargo install --git "https://github.com/$REPO" resq-cli - return + _cargo_ref="default-branch" + else + # The default: a commit SHA, which cannot be repointed. + info "Installing resq-cli from pinned commit $CRATES_COMMIT via cargo ..." + cargo install --git "https://github.com/$REPO" --rev "$CRATES_COMMIT" resq-cli + _cargo_ref="$CRATES_COMMIT" fi - # The default: a commit SHA, which cannot be repointed. - info "Installing resq-cli from pinned commit $CRATES_COMMIT via cargo ..." - cargo install --git "https://github.com/$REPO" --rev "$CRATES_COMMIT" resq-cli + _cargo_bin="${CARGO_INSTALL_ROOT:-${CARGO_HOME:-$HOME/.cargo}}/bin/$BIN_NAME" + write_receipt cargo "$_cargo_ref" "$_cargo_bin" "$(sha256_of "$_cargo_bin" 2>/dev/null)" "n/a" } if [ "${RESQ_FORCE_CARGO:-0}" = "1" ]; then @@ -113,32 +164,49 @@ if [ -z "$TARGET" ]; then fi # ── Resolve release tag ────────────────────────────────────────────────────── -resolve_tag() { - if [ -n "$WANTED_VERSION" ]; then - echo "${TAG_PREFIX}${WANTED_VERSION}" - return - fi - # Find newest resq-cli-v* tag via the releases endpoint. - url="https://api.github.com/repos/$REPO/releases" - curl -fsSL "$url" \ - | sed -n 's/.*"tag_name":[[:space:]]*"\(resq-cli-v[^"]*\)".*/\1/p' \ - | head -1 +# +# Every GitHub API response is captured on its own, so a failed request stops +# the install. These were `curl ... | sed | head` pipelines under `set -eu` +# without pipefail: an API error (network, proxy, the 60/hour unauthenticated +# rate limit) came out as an empty tag, read as "no release exists", and the +# script quietly fell through to building from source. +api_fail() { + fail "GitHub API request failed: $1. Check network/proxy/rate limit and re-run, or set RESQ_FORCE_CARGO=1 to build the pinned commit from source." } -TAG="$(resolve_tag)" + +if [ -n "$WANTED_VERSION" ]; then + TAG="${TAG_PREFIX}${WANTED_VERSION}" +else + releases_json="$(curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=100")" \ + || api_fail "could not list $REPO releases" + # The highest stable resq-cli-vX.Y.Z by number. The API orders releases by + # creation date and this repository releases several crates at once, so + # the first match was the newest-created, not the newest version. A + # pre-release is never picked; drafts are not returned to this caller. + TAG="$(printf '%s\n' "$releases_json" | awk ' + /"tag_name":/ { t = $0; sub(/.*"tag_name":[[:space:]]*"/, "", t); sub(/".*/, "", t); next } + /"prerelease":/ { if (t != "" && $0 ~ /false/) print t; t = "" }' \ + | sed -n "s/^${TAG_PREFIX}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)\$/\1/p" \ + | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)" + [ -z "$TAG" ] || TAG="${TAG_PREFIX}${TAG}" +fi if [ -z "$TAG" ]; then warn "No $TAG_PREFIX* release found — falling back to cargo." cargo_install exit 0 fi info "Resolved release tag: $TAG" +TAG_VERSION="${TAG#"$TAG_PREFIX"}" # ── Find platform asset ────────────────────────────────────────────────────── -asset_url=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/tags/$TAG" \ +release_json="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/tags/$TAG")" \ + || api_fail "could not read release $TAG (does it exist?)" +asset_url=$(printf '%s\n' "$release_json" \ | sed -n 's/.*"browser_download_url":[[:space:]]*"\([^"]*\)".*/\1/p' \ | grep -F "$TARGET" \ | grep -E '\.tar\.gz$|\.zip$' \ | head -1) -sums_url=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/tags/$TAG" \ +sums_url=$(printf '%s\n' "$release_json" \ | sed -n 's/.*"browser_download_url":[[:space:]]*"\([^"]*SHA256SUMS[^"]*\)".*/\1/p' \ | head -1) @@ -150,7 +218,12 @@ fi # ── Download + verify ──────────────────────────────────────────────────────── tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT +staged="" +trap 'rm -rf "$tmp"; [ -z "$staged" ] || rm -f "$staged"' EXIT +# dash (the /bin/sh curl|sh usually lands in) does not run EXIT traps on a fatal +# signal, so an interrupt mid-install would leave the staged binary behind. +# Exiting from the signal handler runs the EXIT trap above. +trap 'exit 130' HUP INT QUIT TERM asset_name=$(basename "$asset_url") info "Downloading $asset_name ..." @@ -173,13 +246,7 @@ if [ -n "$sums_url" ]; then fail "Asset not listed in SHA256SUMS — refusing to install unverified (set RESQ_ALLOW_UNVERIFIED=1 to override)." fi else - if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$tmp/$asset_name" | awk '{print $1}') - elif command -v shasum >/dev/null 2>&1; then - actual=$(shasum -a 256 "$tmp/$asset_name" | awk '{print $1}') - else - actual="" - fi + actual="$(sha256_of "$tmp/$asset_name")" if [ -z "$actual" ]; then if [ "$allow_unverified" = "1" ]; then warn "No sha256sum/shasum available — proceeding unverified (RESQ_ALLOW_UNVERIFIED=1)." @@ -196,6 +263,56 @@ else fail "No SHA256SUMS in release — refusing to install unverified (set RESQ_ALLOW_UNVERIFIED=1 to override)." fi +# ── Verify build provenance ────────────────────────────────────────────────── +# +# SHA256SUMS comes from the same release as the archive, so it proves only that +# the download is intact — anyone able to publish the release can publish +# matching sums. The Sigstore attestation proves who built it: release.yml in +# resq-software/crates, running at this tag, on a GitHub-hosted runner, from the +# commit the tag names, and that commit is on master. The signer is matched +# EXACTLY (--cert-identity); --signer-workflow would accept any workflow path +# with that prefix. +# +# Needs an authenticated gh 2.68+ (`attestation verify --source-digest`). Without one, the +# install continues on the checksum alone and says so; RESQ_REQUIRE_PROVENANCE=1 +# makes that fatal. RESQ_ALLOW_UNVERIFIED=1 skips the check deliberately (for +# when Sigstore or the API is unreachable). A check that RUNS and FAILS is never +# overridable, like a checksum mismatch. +provenance="unchecked" +if [ "$allow_unverified" = "1" ] && [ "${RESQ_REQUIRE_PROVENANCE:-0}" = "1" ]; then + fail "RESQ_REQUIRE_PROVENANCE=1 and RESQ_ALLOW_UNVERIFIED=1 contradict each other — unset one." +elif [ "$allow_unverified" = "1" ]; then + warn "Build provenance NOT checked (RESQ_ALLOW_UNVERIFIED=1)." +# Probe for the flag the check uses, not just the subcommand: gh 2.49-2.67 has +# `attestation verify` without --source-ref/--source-digest (added in 2.68.0), +# and on those every check would "fail" and refuse a good release. +elif command -v gh >/dev/null 2>&1 \ + && gh attestation verify --help 2>&1 | grep -q -- '--source-digest' \ + && gh auth status >/dev/null 2>&1; then + info "Verifying build provenance (Sigstore attestation) ..." + tag_sha="$(gh api "repos/$REPO/commits/$TAG" --jq .sha 2>/dev/null)" || tag_sha="" + [ -n "$tag_sha" ] || fail "Could not resolve $TAG to a commit to check its provenance." + tag_status="$(gh api "repos/$REPO/compare/master...$tag_sha" --jq .status 2>/dev/null)" || tag_status="" + case "$tag_status" in + identical|behind) ;; + *) fail "$TAG points at $tag_sha, which is not on $REPO master (${tag_status:-unknown}) — refusing a release built from unmerged code." ;; + esac + if ! attest_out="$(gh attestation verify "$tmp/$asset_name" --repo "$REPO" \ + --cert-identity "https://github.com/$REPO/.github/workflows/release.yml@refs/tags/$TAG" \ + --cert-oidc-issuer "https://token.actions.githubusercontent.com" \ + --source-ref "refs/tags/$TAG" --source-digest "$tag_sha" \ + --deny-self-hosted-runners 2>&1)"; then + printf '%s\n' "$attest_out" >&2 + fail "Build provenance verification FAILED for $asset_name ($TAG) — refusing to install." + fi + provenance="verified" + info "Provenance verified: built by release.yml at $TAG ($tag_sha)." +elif [ "${RESQ_REQUIRE_PROVENANCE:-0}" = "1" ]; then + fail "RESQ_REQUIRE_PROVENANCE=1, but no authenticated gh 2.68+ (with 'attestation verify --source-digest') is available to check it." +else + warn "Build provenance NOT checked — needs an authenticated gh 2.68+. Trusting SHA256SUMS alone." +fi + # ── Extract ────────────────────────────────────────────────────────────────── case "$asset_name" in *.tar.gz) tar -xzf "$tmp/$asset_name" -C "$tmp" ;; @@ -210,8 +327,29 @@ if [ -z "$src_bin" ]; then fi [ -n "$src_bin" ] || fail "Could not locate '$BIN_NAME' inside $asset_name." -install -m 0755 "$src_bin" "$DEST_DIR/$BIN_NAME" +# ── Install ────────────────────────────────────────────────────────────────── +# +# The new binary has to run and report the version the tag names before it may +# replace anything. It is then staged beside the target and renamed into place, +# so an interrupted install never leaves a half-written resq; the binary it +# replaces is kept as resq.prev. +chmod 0755 "$src_bin" +new_version="$("$src_bin" --version 2>/dev/null | head -1)" || new_version="" +case "$new_version" in + *" $TAG_VERSION"|*" v$TAG_VERSION") ;; + *) fail "$asset_name reports '${new_version:-nothing}' for --version, not $TAG_VERSION — refusing to install it." ;; +esac + +staged="$DEST_DIR/.$BIN_NAME.new.$$" +install -m 0755 "$src_bin" "$staged" +if [ -f "$DEST_DIR/$BIN_NAME" ]; then + cp -p "$DEST_DIR/$BIN_NAME" "$DEST_DIR/$BIN_NAME.prev" \ + || warn "Could not keep the previous binary as $BIN_NAME.prev." +fi +mv -f "$staged" "$DEST_DIR/$BIN_NAME" +staged="" info "Installed $DEST_DIR/$BIN_NAME" +write_receipt release "$TAG" "$DEST_DIR/$BIN_NAME" "$(sha256_of "$DEST_DIR/$BIN_NAME")" "$provenance" case ":$PATH:" in *":$DEST_DIR:"*) ;; diff --git a/tests/installers/pinning.sh b/tests/installers/pinning.sh index d847f1b..cdc07dc 100755 --- a/tests/installers/pinning.sh +++ b/tests/installers/pinning.sh @@ -44,7 +44,10 @@ printf '%s\n' "$*" > "$CARGO_ARGV_OUT" STUB chmod +x "$_tmp/bin/cargo" + # XDG_CONFIG_HOME: the script writes an install receipt there, and must + # not touch the real one. CARGO_ARGV_OUT="$_tmp/argv" \ + XDG_CONFIG_HOME="$_tmp/config" \ PATH="$_tmp/bin:$PATH" \ RESQ_FORCE_CARGO=1 \ RESQ_INSTALL_DIR="$_tmp/dest" \ diff --git a/tests/installers/release-path.sh b/tests/installers/release-path.sh new file mode 100644 index 0000000..88f9eec --- /dev/null +++ b/tests/installers/release-path.sh @@ -0,0 +1,291 @@ +#!/bin/sh +# Copyright 2026 ResQ Systems, Inc. +# SPDX-License-Identifier: Apache-2.0 +# +# The release-binary path of scripts/install-resq.sh: which release it picks, +# what it refuses, and what it leaves behind. +# +# Hermetic. curl, gh and cargo are stubs serving fixtures built here; every +# other tool is reached through a toolbox of symlinks, so no real gh, cargo or +# network is involved. Each case runs under `env -i` with its own HOME, +# XDG_CONFIG_HOME and install dir — the script writes an install receipt, and +# a test must never write one into the real ~/.config. +# +# Run from the repository root: sh tests/installers/release-path.sh +set -eu +SCRIPT="${1:-scripts/install-resq.sh}" +[ -f "$SCRIPT" ] || { printf 'fail %s not found — run from the repository root\n' "$SCRIPT" >&2; exit 1; } +SCRIPT="$(cd "$(dirname "$SCRIPT")" && pwd)/$(basename "$SCRIPT")" + +pass=0 +fail=0 +check() { + if [ "$2" = "0" ]; then + pass=$((pass + 1)); printf ' PASS %s\n' "$1" + else + fail=$((fail + 1)); printf ' FAIL %s\n' "$1" + [ -z "${3:-}" ] || printf '%s\n' "$3" | sed 's/^/ /' + fi +} + +ROOT="$(mktemp -d)" +trap 'rm -rf "$ROOT"' EXIT +FIX="$ROOT/fix" +mkdir -p "$FIX/assets" "$ROOT/tools" "$ROOT/stubs/base" "$ROOT/stubs/gh" + +# ── Toolbox: only the real tools the script needs ─────────────────────────── +for t in sh sed awk grep sort tail head mktemp tar gzip find install cp mv rm \ + mkdir cat basename dirname date uname cut tr chmod env sha256sum shasum; do + p="$(command -v "$t" 2>/dev/null)" || continue + ln -s "$p" "$ROOT/tools/$t" +done + +# ── Fixtures ──────────────────────────────────────────────────────────────── +TARGETS="x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin" + +# Stock macOS has shasum but not sha256sum; both print " ". +sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } + +# release +release() { + _assets="" + for _t in $TARGETS; do + _name="resq-cli-resq-cli-v$1-$_t.tar.gz" + _stage="$(mktemp -d)" + printf '#!/bin/sh\necho "resq %s"\n' "$2" > "$_stage/resq" + chmod +x "$_stage/resq" + tar -czf "$FIX/assets/$_name" -C "$_stage" resq + rm -rf "$_stage" + _assets="$_assets $_name" + done + # shellcheck disable=SC2086 + (cd "$FIX/assets" && sha256 $_assets > "SHA256SUMS-$1") + { + printf '{\n "tag_name": "resq-cli-v%s",\n "prerelease": false,\n "assets": [\n' "$1" + for _a in $_assets "SHA256SUMS-$1"; do + printf ' { "browser_download_url": "https://dl.invalid/%s" },\n' "$_a" + done + printf ' ]\n}\n' + } > "$FIX/release-resq-cli-v$1.json" +} +release 0.5.9 0.5.9 +release 0.5.10 0.5.10 +release 0.5.11 0.0.1 # binary lies about its version +release 0.5.12 0.5.12 +# 0.5.12's checksum file names the right asset with the wrong digest. +sed 's/^[0-9a-f]\{64\}/0000000000000000000000000000000000000000000000000000000000000000/' \ + "$FIX/assets/SHA256SUMS-0.5.12" > "$FIX/assets/x" && mv "$FIX/assets/x" "$FIX/assets/SHA256SUMS-0.5.12" + +# The list, in the API's creation-date order: 0.5.9 created last, so a +# first-match picker takes it; 0.6.0 is a pre-release; resq-tui is another crate. +cat > "$FIX/releases.json" <<'JSON' +[ + { + "tag_name": "resq-cli-v0.5.9", + "draft": false, + "prerelease": false + }, + { + "tag_name": "resq-tui-v9.9.9", + "draft": false, + "prerelease": false + }, + { + "tag_name": "resq-cli-v0.6.0", + "draft": false, + "prerelease": true + }, + { + "tag_name": "resq-cli-v0.5.10", + "draft": false, + "prerelease": false + } +] +JSON + +# ── Stubs ─────────────────────────────────────────────────────────────────── +cat > "$ROOT/stubs/base/curl" <<'STUB' +#!/bin/sh +out=""; url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +echo "$url" >> "$FIX/curl.log" +case "$url" in + *"/releases?per_page=100") + [ "${CURL_API_FAIL:-0}" = 1 ] && exit 22 + src="$FIX/releases.json" ;; + *"/releases/tags/"*) src="$FIX/release-${url##*/}.json" ;; + https://dl.invalid/*) src="$FIX/assets/${url##*/}" ;; + *) exit 22 ;; +esac +[ -f "$src" ] || exit 22 +if [ -n "$out" ]; then cp "$src" "$out"; else cat "$src"; fi +STUB +cat > "$ROOT/stubs/base/cargo" <<'STUB' +#!/bin/sh +echo "$*" >> "$FIX/cargo.log" +STUB +cat > "$ROOT/stubs/gh/gh" <<'STUB' +#!/bin/sh +echo "$*" >> "$FIX/gh.log" +case "$1 ${2:-}" in + "attestation verify") + case " $* " in + *" --help "*) + # gh 2.49-2.67 has the subcommand but not these flags. + [ "${GH_OLD:-0}" = 1 ] || echo " --source-digest string --source-ref string" + exit 0 ;; + esac + exit "${GH_VERIFY_RC:-0}" ;; + "auth status") exit 0 ;; + api\ *) + case "$2" in + */commits/*) echo "${GH_TAG_SHA:-0123456789abcdef0123456789abcdef01234567}" ;; + */compare/*) echo "${GH_COMPARE:-behind}" ;; + esac + exit 0 ;; +esac +exit 1 +STUB +chmod +x "$ROOT"/stubs/*/* + +# run_case [WITH_GH=1] [VAR=value ...] [-- script args...] +# Sets OUT, RC and C (the case dir: dest/, config/resq/install.json). +run_case() { + C="$(mktemp -d "$ROOT/case.XXXXXX")" + mkdir -p "$C/dest" "$C/home" "$C/config" + rm -f "$FIX/curl.log" "$FIX/gh.log" "$FIX/cargo.log" + _path="$ROOT/stubs/base:$ROOT/tools" + _envs="" + _args="" + _in_args=0 + for _a in "$@"; do + if [ "$_in_args" = 1 ]; then _args="$_args $_a"; continue; fi + case "$_a" in + --) _in_args=1 ;; + WITH_GH=1) _path="$ROOT/stubs/gh:$_path" ;; + *) _envs="$_envs $_a" ;; + esac + done + set +e + # shellcheck disable=SC2086 + OUT="$(cd "$C" && env -i PATH="$_path" HOME="$C/home" XDG_CONFIG_HOME="$C/config" \ + RESQ_INSTALL_DIR="$C/dest" FIX="$FIX" $_envs sh "$SCRIPT" $_args 2>&1)" + RC=$? + set -e +} +has() { case "$OUT" in *"$1"*) return 0 ;; *) return 1 ;; esac; } +receipt() { cat "$C/config/resq/install.json" 2>/dev/null || true; } + +printf '\n== install-resq.sh release path ==\n' + +# 1. An API failure is an error, not "no releases": no silent source build. +run_case CURL_API_FAIL=1 +r=1; [ "$RC" -ne 0 ] && has "GitHub API request failed" && [ ! -f "$FIX/cargo.log" ] && r=0 +check "a failed API request stops the install instead of building from source" "$r" "rc=$RC +$OUT" + +# 2. Highest stable version by number, not first-created, not a pre-release. +run_case +r=1; grep -q '/releases/tags/resq-cli-v0.5.10$' "$FIX/curl.log" && r=0 +check "picks the highest stable resq-cli version (0.5.10), not first-listed or pre-release" "$r" "$(cat "$FIX/curl.log")" + +# 3. Without gh: installs on the checksum, says provenance was not checked. +r=1; [ "$RC" -eq 0 ] && has "provenance NOT checked" \ + && [ "$("$C/dest/resq" --version)" = "resq 0.5.10" ] && r=0 +check "without gh: installs on SHA256SUMS and warns provenance was not checked" "$r" "rc=$RC +$OUT" +r=1; receipt | grep -q '"method": "release"' && receipt | grep -q '"provenance": "unchecked"' \ + && receipt | grep -q "\"path\": \"$C/dest/resq\"" && receipt | grep -q '"ref": "resq-cli-v0.5.10"' && r=0 +check "writes a release receipt recording provenance=unchecked" "$r" "$(receipt)" + +# 4. With gh: exact signer identity, source ref and digest, hosted runners only. +run_case WITH_GH=1 GH_TAG_SHA=abcabcabcabcabcabcabcabcabcabcabcabcabca +v="$(grep '^attestation verify ' "$FIX/gh.log" | grep -v -- '--help' || true)" +r=1; [ "$RC" -eq 0 ] \ + && case "$v" in *"--cert-identity https://github.com/resq-software/crates/.github/workflows/release.yml@refs/tags/resq-cli-v0.5.10"*) true ;; *) false ;; esac \ + && case "$v" in *"--cert-oidc-issuer https://token.actions.githubusercontent.com"*) true ;; *) false ;; esac \ + && case "$v" in *"--source-ref refs/tags/resq-cli-v0.5.10"*) true ;; *) false ;; esac \ + && case "$v" in *"--source-digest abcabcabcabcabcabcabcabcabcabcabcabcabca"*) true ;; *) false ;; esac \ + && case "$v" in *"--deny-self-hosted-runners"*) true ;; *) false ;; esac \ + && case "$v" in *"--signer-workflow"*) false ;; *) true ;; esac && r=0 +check "with gh: verifies the exact release.yml@tag identity, source ref and digest" "$r" "rc=$RC verify call: $v +$OUT" +r=1; receipt | grep -q '"provenance": "verified"' && r=0 +check "a verified install records provenance=verified" "$r" "$(receipt)" + +# 5. A failed verification is fatal and installs nothing. +run_case WITH_GH=1 GH_VERIFY_RC=1 +r=1; [ "$RC" -ne 0 ] && has "verification FAILED" && [ ! -e "$C/dest/resq" ] && r=0 +check "a failed attestation refuses the install" "$r" "rc=$RC +$OUT" + +# 6. A tag on a commit that is not on master is refused before verifying. +run_case WITH_GH=1 GH_COMPARE=diverged +r=1; [ "$RC" -ne 0 ] && has "not on resq-software/crates master" && [ ! -e "$C/dest/resq" ] && r=0 +check "a release tag on unmerged code is refused" "$r" "rc=$RC +$OUT" + +# 7. Provenance can be made mandatory. +run_case RESQ_REQUIRE_PROVENANCE=1 +r=1; [ "$RC" -ne 0 ] && [ ! -e "$C/dest/resq" ] && r=0 +check "RESQ_REQUIRE_PROVENANCE=1 without gh refuses" "$r" "rc=$RC +$OUT" + +# 8. RESQ_ALLOW_UNVERIFIED skips the check deliberately, and says so. +run_case WITH_GH=1 RESQ_ALLOW_UNVERIFIED=1 +r=1; [ "$RC" -eq 0 ] && has "NOT checked (RESQ_ALLOW_UNVERIFIED=1)" \ + && ! grep -qs '^attestation verify .*--cert-identity' "$FIX/gh.log" && r=0 +check "RESQ_ALLOW_UNVERIFIED=1 skips provenance loudly" "$r" "rc=$RC +$OUT" + +# 8b. Asking for both is a contradiction, not a silent win for "unverified". +run_case WITH_GH=1 RESQ_ALLOW_UNVERIFIED=1 RESQ_REQUIRE_PROVENANCE=1 +r=1; [ "$RC" -ne 0 ] && has "contradict" && [ ! -e "$C/dest/resq" ] && r=0 +check "RESQ_REQUIRE_PROVENANCE=1 with RESQ_ALLOW_UNVERIFIED=1 refuses" "$r" "rc=$RC +$OUT" + +# 8c. A gh too old for --source-digest is "cannot check", not "check failed". +run_case WITH_GH=1 GH_OLD=1 +r=1; [ "$RC" -eq 0 ] && has "provenance NOT checked" && ! has "verification FAILED" \ + && ! grep -qs '^attestation verify .*--cert-identity' "$FIX/gh.log" && r=0 +check "gh without --source-digest (2.49-2.67) installs unchecked instead of failing" "$r" "rc=$RC +$OUT" + +# 9. A binary that misreports its version never replaces the current one. +run_case -- 0.5.11 +r=1; [ "$RC" -ne 0 ] && has "reports 'resq 0.0.1'" && [ ! -e "$C/dest/resq" ] && r=0 +check "a binary reporting the wrong version is refused" "$r" "rc=$RC +$OUT" + +# 10. A checksum mismatch is still fatal (regression guard). +run_case -- 0.5.12 +r=1; [ "$RC" -ne 0 ] && has "SHA256 mismatch" && [ ! -e "$C/dest/resq" ] && r=0 +check "a SHA256 mismatch still refuses the install" "$r" "rc=$RC +$OUT" + +# 11. The replaced binary is kept as resq.prev; no staging file is left behind. +run_case -- 0.5.9 +first="$C" +r=1 +if [ "$RC" -eq 0 ]; then + set +e + OUT="$(cd "$first" && env -i PATH="$ROOT/stubs/base:$ROOT/tools" HOME="$first/home" \ + XDG_CONFIG_HOME="$first/config" RESQ_INSTALL_DIR="$first/dest" FIX="$FIX" \ + sh "$SCRIPT" 0.5.10 2>&1)" + RC=$? + set -e + [ "$RC" -eq 0 ] && [ "$("$first/dest/resq" --version)" = "resq 0.5.10" ] \ + && [ "$("$first/dest/resq.prev" --version)" = "resq 0.5.9" ] \ + && [ -z "$(find "$first/dest" -name '.resq.new.*')" ] && r=0 +fi +check "an upgrade keeps the previous binary as resq.prev and leaves no staging file" "$r" "rc=$RC +$OUT" + +printf '\n%s passed, %s failed\n\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/tests/run_all.sh b/tests/run_all.sh index bc5eb64..e3ced07 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -91,6 +91,10 @@ printf '\nResQ dev test suites\n\n' # Installer pinning. Stubs cargo; touches no network and no toolchain. run_suite "installers" "sh tests/installers/pinning.sh" '[0-9]+ passed, 0 failed' +# install-resq.sh release path: tag choice, provenance, staging, receipt. +# Stubs curl/gh/cargo; runs under env -i with a scratch HOME. +run_suite "release-path" "sh tests/installers/release-path.sh" '[0-9]+ passed, 0 failed' + # Library units: OS detection and package-manager selection, fully stubbed. run_suite "lib" "bash tests/lib/packages.sh" '[0-9]+ passed, 0 failed'