diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84594be2e..52caa05e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -357,6 +357,13 @@ jobs: path: packages/opencode/dist/ merge-multiple: true + - name: Generate checksums + # Single checksums.txt (sha256sum format: " ") shipped + # as a release asset. The curl and PowerShell installers fetch it and verify + # the downloaded archive before extracting. + working-directory: packages/opencode/dist + run: sha256sum *.tar.gz *.zip > checksums.txt + - name: Create GitHub Release uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: @@ -366,5 +373,6 @@ jobs: files: | packages/opencode/dist/*.tar.gz packages/opencode/dist/*.zip + packages/opencode/dist/checksums.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/install b/install index 2cf359ba0..e228ac8cf 100755 --- a/install +++ b/install @@ -356,6 +356,59 @@ download_with_progress() { return $ret } +# Verify the downloaded archive against the release's checksums.txt. +# Hard-fails on a real mismatch; soft-skips when checksums.txt can't be fetched +# (older release, network blip) or has no entry, so pinned installs of +# pre-checksums releases keep working. +verify_checksum() { + local file="$1" + local name="$2" + # $url ends in /$filename — strip it to get the release base, append checksums.txt. + local checksums_url="${url%/*}/checksums.txt" + + local sums + if ! sums=$(curl --fail -sL "$checksums_url" 2>/dev/null); then + print_message info "${MUTED}Skipping integrity check — checksums.txt not published for this release${NC}" + return 0 + fi + + # checksums.txt is sha256sum format: " " (sha256sum may + # prefix the name with '*' in binary mode — tolerate it). + local expected + expected=$(printf '%s\n' "$sums" | awk -v f="$name" '{ n=$2; sub(/^\*/,"",n); if (n==f) { print $1; exit } }') + if [ -z "$expected" ]; then + print_message info "${MUTED}Skipping integrity check — no checksum entry for $name${NC}" + return 0 + fi + + local actual + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$file" | cut -d' ' -f1) + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$file" | cut -d' ' -f1) + else + print_message info "${MUTED}Skipping integrity check — no sha256 tool available${NC}" + return 0 + fi + + if [ "$actual" != "$expected" ]; then + print_message error "Checksum mismatch for $name" + print_message error " expected: $expected" + print_message error " actual: $actual" + # Clean up via the file's own directory rather than the caller's $tmp_dir, + # so this stays self-contained and doesn't depend on a dynamically-scoped + # local from download_and_install. Guard against a pathological $file + # (empty or root-level) that would make dirname resolve to "." or "/". + local cleanup_dir + cleanup_dir=$(dirname "$file") + if [ -n "$cleanup_dir" ] && [ "$cleanup_dir" != "." ] && [ "$cleanup_dir" != "/" ]; then + rm -rf "$cleanup_dir" + fi + exit 1 + fi + print_message info "${MUTED}Verified ${NC}$name${MUTED} (sha256)${NC}" +} + download_and_install() { print_message info "\n${MUTED}Installing ${NC}altimate ${MUTED}version: ${NC}$specific_version" local tmp_dir="${TMPDIR:-/tmp}/altimate_install_$$" @@ -367,6 +420,8 @@ download_and_install() { curl --fail -# -L -o "$tmp_dir/$filename" "$url" fi + verify_checksum "$tmp_dir/$filename" "$filename" + # Extract only the expected binary member rather than the whole archive. # The current build only puts a single file in each archive, but listing # the member explicitly makes a future "tars a whole directory" mistake diff --git a/install.ps1 b/install.ps1 index bcab7223f..0f00fbf63 100644 --- a/install.ps1 +++ b/install.ps1 @@ -3,7 +3,7 @@ # # Mirrors ./install (the bash installer for macOS/Linux): it downloads the # Bun-compiled standalone executable (altimate.exe) from GitHub releases and -# drops it in %USERPROFILE%\.altimate\bin — it does NOT depend on npm/Node. +# drops it in %USERPROFILE%\.altimate\bin - it does NOT depend on npm/Node. # # Usage: # powershell -c "irm https://www.altimate.sh/install.ps1 | iex" @@ -64,8 +64,8 @@ if ($Help) { exit 0 } -# A single P/Invoke type carries both native calls we need — the AVX2 CPU probe -# (kernel32) and the PATH-change broadcast (user32) — so we Add-Type once instead +# A single P/Invoke type carries both native calls we need - the AVX2 CPU probe +# (kernel32) and the PATH-change broadcast (user32) - so we Add-Type once instead # of compiling a throwaway type per call site. function Initialize-Native { if (-not ("Win32.AltimateNative" -as [type])) { @@ -77,6 +77,46 @@ public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wP } } +# Verify a downloaded archive against the release's checksums.txt. +# Hard-fails (throws) on a real mismatch. Soft-skips when checksums.txt can't be +# fetched (older release, network blip) or has no entry for this file, so pinned +# installs of pre-checksums releases keep working. +function Test-Checksum { + param([string]$Path, [string]$Name, [string]$ChecksumsUrl) + + $sums = $null + try { + $resp = Invoke-WebRequest -Uri $ChecksumsUrl -UseBasicParsing + # On Windows PowerShell 5.1, .Content is a Byte[] (not a String) whenever the + # response isn't a text-recognized content-type - and GitHub serves release + # assets as application/octet-stream. A raw Byte[] coerces to a "49 50 51 ..." + # decimal string when split, so verification would silently soft-skip on the + # default Windows shell. Decode the bytes explicitly to recover real text. + if ($resp.Content -is [byte[]]) { + $sums = [System.Text.Encoding]::UTF8.GetString($resp.Content) + } else { + $sums = $resp.Content + } + } catch { + Write-Muted "Skipping integrity check - checksums.txt not published for this release" + return + } + + # checksums.txt is sha256sum format: " " (one entry per line). + $line = ($sums -split "`n") | Where-Object { $_ -match "\s\*?$([regex]::Escape($Name))\s*$" } | Select-Object -First 1 + if (-not $line) { + Write-Muted "Skipping integrity check - no checksum entry for $Name" + return + } + + $expected = (($line -split '\s+')[0]).ToLower() + $actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + throw "Checksum mismatch for $Name (expected $expected, got $actual)" + } + Write-Muted "Verified $Name (sha256)" +} + # --------------------------------------------------------------------------- # Architecture / baseline detection # --------------------------------------------------------------------------- @@ -101,14 +141,14 @@ function Test-Avx2 { Initialize-Native return [bool][Win32.AltimateNative]::IsProcessorFeaturePresent(40) } catch { - # If detection fails, assume no AVX2 and fall back to the baseline build — + # If detection fails, assume no AVX2 and fall back to the baseline build - # the baseline binary runs everywhere, an AVX2 binary on a non-AVX2 CPU crashes. return $false } } # --------------------------------------------------------------------------- -# Resolve version (once) — latest tag or a pinned release +# Resolve version (once) - latest tag or a pinned release # --------------------------------------------------------------------------- if ([string]::IsNullOrWhiteSpace($Version)) { $useLatest = $true @@ -170,11 +210,19 @@ function Install-Target { if ($Baseline) { $target = "$target-baseline" } $filename = "$App-$target.zip" - if ($useLatest) { - $url = "https://github.com/AltimateAI/altimate-code/releases/latest/download/$filename" + # Pin BOTH the archive and checksums.txt to the same resolved release. The + # mutable releases/latest/download URL would fetch the two assets in separate + # requests, so a release published mid-install could hand back an archive from + # one release and checksums from another -> a spurious hard-fail. We resolve + # the concrete tag up front ($specificVersion), so pin to it. Only fall back + # to the mutable latest/ URL when the version genuinely couldn't be resolved. + if ($useLatest -and -not $specificVersion) { + $base = "https://github.com/AltimateAI/altimate-code/releases/latest/download" } else { - $url = "https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion/$filename" + $base = "https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion" } + $url = "$base/$filename" + $checksumsUrl = "$base/checksums.txt" Write-Host "" Write-Host "Installing $App version: $specificVersion" @@ -184,12 +232,6 @@ function Install-Target { $zipPath = Join-Path $tmpDir $filename try { - # NOTE: integrity verification (SHA256/signature) of the archive is - # intentionally deferred to match the bash installer's posture — both rely - # on HTTPS from github.com release assets. Releases do not currently publish - # a checksums file; adding one + verifying it in both installers is tracked - # as a follow-up. See PR #930 discussion. - # # Prefer curl.exe (ships with Windows 10 1803+) for a fast download with # --fail so HTTP errors don't write an error page to disk; fall back to # Invoke-WebRequest where curl.exe is unavailable. @@ -201,6 +243,10 @@ function Install-Target { Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing } + # Integrity check: hard-fail on mismatch; skip (with notice) when the release + # predates checksums.txt or the fetch fails, so older pinned installs still work. + Test-Checksum -Path $zipPath -Name $filename -ChecksumsUrl $checksumsUrl + Expand-Archive -Path $zipPath -DestinationPath $tmpDir -Force $extracted = Join-Path $tmpDir $BinaryName if (-not (Test-Path $extracted)) { @@ -210,7 +256,7 @@ function Install-Target { # Windows locks a running .exe, so `altimate upgrade` (which re-runs this # installer) can't overwrite the binary that is currently executing. Windows - # *does* allow renaming a running exe — move the old one aside first, then + # *does* allow renaming a running exe - move the old one aside first, then # drop the new one in. Best-effort cleanup of the stale copy afterward. if (Test-Path $InstalledBinary) { $stale = "$InstalledBinary.old" @@ -237,7 +283,7 @@ if (-not $needsBaseline) { & $InstalledBinary --version *> $null $code = $LASTEXITCODE if ($code -eq 3221225501 -or $code -eq 1073741795 -or $code -eq -1073741795) { - Write-Muted "CPU lacks AVX2 — reinstalling the baseline build" + Write-Muted "CPU lacks AVX2 - reinstalling the baseline build" Install-Target -Baseline:$true } } diff --git a/packages/opencode/test/install/checksum-verification.test.ts b/packages/opencode/test/install/checksum-verification.test.ts new file mode 100644 index 000000000..149ff57a5 --- /dev/null +++ b/packages/opencode/test/install/checksum-verification.test.ts @@ -0,0 +1,75 @@ +/** + * Release-archive integrity verification across the install surface. + * + * The release publishes a checksums.txt asset; both installers fetch it and + * verify the downloaded archive (sha256) before extracting — hard-fail on + * mismatch, soft-skip when the file is absent (older pinned releases). + */ +import { describe, test, expect } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const REPO_ROOT = join(import.meta.dir, "../../../..") +const BASH_INSTALL = readFileSync(join(REPO_ROOT, "install"), "utf-8") +const PS1 = readFileSync(join(REPO_ROOT, "install.ps1"), "utf-8") +const RELEASE_YML = readFileSync(join(REPO_ROOT, ".github/workflows/release.yml"), "utf-8") + +describe("release publishes checksums", () => { + test("release.yml generates checksums.txt and uploads it", () => { + expect(RELEASE_YML).toContain("sha256sum *.tar.gz *.zip > checksums.txt") + expect(RELEASE_YML).toContain("packages/opencode/dist/checksums.txt") + }) +}) + +describe("bash installer verifies checksums", () => { + test("fetches checksums.txt and compares sha256", () => { + expect(BASH_INSTALL).toContain("checksums.txt") + expect(BASH_INSTALL).toMatch(/sha256sum|shasum -a 256/) + }) + + test("hard-fails on mismatch", () => { + expect(BASH_INSTALL).toContain("Checksum mismatch") + expect(BASH_INSTALL).toContain("verify_checksum") + }) +}) + +describe("PowerShell installer verifies checksums", () => { + test("fetches checksums.txt and compares sha256", () => { + expect(PS1).toContain("checksums.txt") + expect(PS1).toContain("Get-FileHash") + expect(PS1).toContain("Test-Checksum") + }) + + test("hard-fails on mismatch before extracting", () => { + expect(PS1).toContain("Checksum mismatch") + // The verify call must precede the actual extraction call (not the + // Expand-Archive mention in the top-of-file ProgressPreference comment). + expect(PS1.indexOf("Test-Checksum -Path")).toBeLessThan(PS1.indexOf("Expand-Archive -Path")) + }) + + test("decodes a Byte[] checksums.txt body (Windows PowerShell 5.1)", () => { + // GitHub serves release assets as octet-stream, so PS 5.1 returns .Content + // as Byte[]; without an explicit decode it coerces to decimal text and the + // check silently soft-skips. See test/windows/install.Tests.ps1 for the + // behavioral guard. + expect(PS1).toContain("-is [byte[]]") + expect(PS1).toContain("[System.Text.Encoding]::UTF8.GetString") + }) +}) + +describe("archive and checksums come from the same release (no latest/ race)", () => { + test("bash derives the checksums URL from the same base as the archive", () => { + // verify_checksum builds checksums_url from the archive's own URL (${url%/*}), + // so the two are always fetched from the same release path. + expect(BASH_INSTALL).toContain('checksums_url="${url%/*}/checksums.txt"') + }) + + test("PowerShell pins both URLs to the resolved release tag (cubic P2)", () => { + // The archive and checksums.txt share one $base; that base is the resolved + // tag, so a release published mid-install can't hand back mismatched assets. + // Falls back to latest/ only when the version couldn't be resolved. + expect(PS1).toContain('$url = "$base/$filename"') + expect(PS1).toContain('$checksumsUrl = "$base/checksums.txt"') + expect(PS1).toContain('$base = "https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion"') + }) +}) diff --git a/packages/opencode/test/release-validation/windows-installer-930-codex.test.ts b/packages/opencode/test/release-validation/windows-installer-930-codex.test.ts index 92e609129..8f429a3df 100644 --- a/packages/opencode/test/release-validation/windows-installer-930-codex.test.ts +++ b/packages/opencode/test/release-validation/windows-installer-930-codex.test.ts @@ -24,8 +24,12 @@ function upgradePowershellBlock() { describe("PR #930 install.ps1 release URL construction", () => { test("uses only HTTPS GitHub release URLs for Windows zip assets", () => { - expect(INSTALL_PS1).toContain('"https://github.com/AltimateAI/altimate-code/releases/latest/download/$filename"') - expect(INSTALL_PS1).toContain('"https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion/$filename"') + // The archive and checksums.txt share one $base so they always resolve to the + // same release (see verify_checksum / Test-Checksum). $base is the latest + // download path or the pinned release tag; $url and $checksumsUrl derive from it. + expect(INSTALL_PS1).toContain('$base = "https://github.com/AltimateAI/altimate-code/releases/latest/download"') + expect(INSTALL_PS1).toContain('$base = "https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion"') + expect(INSTALL_PS1).toContain('$url = "$base/$filename"') expect(INSTALL_PS1).toContain('"https://api.github.com/repos/AltimateAI/altimate-code/releases/latest"') expect(INSTALL_PS1).not.toMatch(/http:\/\/(?:github\.com|api\.github\.com|www\.altimate\.sh)/) }) @@ -62,9 +66,13 @@ describe("PR #930 install.ps1 release URL construction", () => { }) describe("PR #930 install.ps1 download and archive safety", () => { - // BUG: install.ps1 currently documents that SHA256/signature verification is deferred - // and relies only on HTTPS. Release assets should be verified before extraction. - test.todo("verifies downloaded archive integrity with SHA256 or a signature before extraction", () => {}) + test("verifies downloaded archive integrity with SHA256 before extraction", () => { + // Closed by the checksum-verification work: Test-Checksum fetches checksums.txt + // and compares SHA256, and the verify call precedes the actual extraction. + expect(INSTALL_PS1).toContain("Test-Checksum -Path $zipPath") + expect(INSTALL_PS1).toContain("Get-FileHash -Path $Path -Algorithm SHA256") + expect(INSTALL_PS1.indexOf("Test-Checksum -Path")).toBeLessThan(INSTALL_PS1.indexOf("Expand-Archive -Path")) + }) test("fails curl.exe downloads on HTTP errors and checks curl exit status", () => { const installTarget = scriptBlock("function Install-Target", "$needsBaseline") diff --git a/test/windows/install.Tests.ps1 b/test/windows/install.Tests.ps1 index 100892007..9abea83e2 100644 --- a/test/windows/install.Tests.ps1 +++ b/test/windows/install.Tests.ps1 @@ -1,7 +1,7 @@ # Pester behavioral tests for install.ps1 (the Windows standalone installer). # # These run the real script as a subprocess on Windows PowerShell so they -# exercise actual behavior — not just substring matching. They deliberately +# exercise actual behavior - not just substring matching. They deliberately # stop the script early (via -Help or an unknown -Version) so no 268 MB binary # is ever downloaded, while still covering the risky branches: argument # parsing, the WOW64 architecture fix, and unknown-version rejection. @@ -56,7 +56,7 @@ Describe "install.ps1 -Help" { Describe "install.ps1 architecture detection" { It "detects AMD64 under WOW64 (32-bit PowerShell on 64-bit Windows)" { - # PROCESSOR_ARCHITECTURE=x86 but PROCESSOR_ARCHITEW6432=AMD64 → real 64-bit box. + # PROCESSOR_ARCHITECTURE=x86 but PROCESSOR_ARCHITEW6432=AMD64 -> real 64-bit box. # Using an unknown version makes the script stop at the release 404 check, # which it can only reach if the WOW64 arch check let it past. $r = Invoke-Installer -ScriptArgs @("-Version", "0.0.0-nonexistent") -Env @{ @@ -96,3 +96,72 @@ Describe "install.ps1 version handling" { $r.Output | Should -Match "Available releases" } } + +Describe "install.ps1 Test-Checksum" { + # Exercise the real Test-Checksum function in isolation. install.ps1 runs + # top-to-bottom (arch detection, version resolution, exit) so it can't just be + # dot-sourced; instead extract the function via the AST and define it here, + # alongside a recording Write-Muted stub and a fake Invoke-WebRequest that + # returns canned content. + BeforeAll { + $src = Get-Content -Raw $script:InstallScript + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($src, [ref]$tokens, [ref]$errors) + $def = $ast.Find({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Test-Checksum" + }, $true) + if (-not $def) { throw "Test-Checksum not found in install.ps1" } + . ([ScriptBlock]::Create($def.Extent.Text)) + + # Records what Test-Checksum reports, so we can tell a real "Verified" from a + # silent "Skipping integrity check" soft-skip. + $script:Muted = [System.Collections.Generic.List[string]]::new() + function Write-Muted { param([string]$Message) $script:Muted.Add($Message) } + + # Fake Invoke-WebRequest: a function shadows the cmdlet, returning whatever + # $script:FakeContent is set to (string or Byte[]) as .Content. + function Invoke-WebRequest { param($Uri, [switch]$UseBasicParsing) [pscustomobject]@{ Content = $script:FakeContent } } + + function New-FixtureArchive { + $tmp = New-TemporaryFile + "altimate-archive-fixture" | Set-Content -NoNewline -Path $tmp + return $tmp + } + } + + BeforeEach { $script:Muted.Clear() } + + It "verifies a matching archive when checksums.txt is served as a String (PS 7)" { + $tmp = New-FixtureArchive + $name = Split-Path $tmp -Leaf + $hash = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower() + $script:FakeContent = "$hash $name`n" + { Test-Checksum -Path $tmp -Name $name -ChecksumsUrl "https://x/checksums.txt" } | Should -Not -Throw + ($script:Muted -join "`n") | Should -Match "Verified" + ($script:Muted -join "`n") | Should -Not -Match "Skipping" + Remove-Item $tmp -Force + } + + It "verifies a matching archive when checksums.txt is served as Byte[] (Windows PowerShell 5.1)" { + # The regression guard: GitHub serves release assets as octet-stream, so on + # PS 5.1 .Content is a Byte[]. Without the explicit UTF8 decode it coerces to + # a "49 50 51 ..." decimal string, no entry matches, and the check soft-skips. + $tmp = New-FixtureArchive + $name = Split-Path $tmp -Leaf + $hash = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower() + $script:FakeContent = [System.Text.Encoding]::UTF8.GetBytes("$hash $name`n") + { Test-Checksum -Path $tmp -Name $name -ChecksumsUrl "https://x/checksums.txt" } | Should -Not -Throw + ($script:Muted -join "`n") | Should -Match "Verified" + ($script:Muted -join "`n") | Should -Not -Match "Skipping" + Remove-Item $tmp -Force + } + + It "hard-fails on a real checksum mismatch (Byte[] content)" { + $tmp = New-FixtureArchive + $name = Split-Path $tmp -Leaf + $script:FakeContent = [System.Text.Encoding]::UTF8.GetBytes((("0" * 64) + " $name`n")) + { Test-Checksum -Path $tmp -Name $name -ChecksumsUrl "https://x/checksums.txt" } | Should -Throw + Remove-Item $tmp -Force + } +}