From 95975f4359abca95c108636f3e990dc714bb1274 Mon Sep 17 00:00:00 2001 From: Derek Date: Wed, 14 Oct 2026 16:30:07 +1100 Subject: [PATCH] fix: make install.ps1 fail closed on checksums install.ps1 installed whatever it downloaded when the sums file was missing, did not name the asset or could not be fetched. It now refuses, the way install.sh does. - looks for macbash-windows-.sha256 first (what Cross-build publishes), then checksums.sha256, and matches the asset name exactly, case included - a missing sums file, a sums file without the asset, a malformed or duplicate entry, a mismatch or a download error throws and installs nothing - tests/install_ps1.rs runs the rendered script under pwsh against a loopback host, and skips when pwsh is not on PATH - the loopback server and which() move from tests/install_sh.rs into tests/common so both installer tests share them - .hyperi-ci.yaml sets build.rust.optimize.allocator: system, since Cargo.toml declares no jemalloc feature and every build warned and fell back - packaging/README.md: macOS and Windows builds ship - docs/release.md and the cross-build.yml header: the tag push does start Cross-build, because the tag sits on the released commit and the skip-ci mark is on the chore commit after it --- .github/workflows/cross-build.yml | 12 +- .hyperi-ci.yaml | 7 +- docs/release.md | 25 +-- packaging/README.md | 15 +- packaging/templates/install.ps1 | 78 ++++++--- tests/common/mod.rs | 85 +++++++++- tests/install_ps1.rs | 265 ++++++++++++++++++++++++++++++ tests/install_sh.rs | 80 +-------- 8 files changed, 439 insertions(+), 128 deletions(-) create mode 100644 tests/install_ps1.rs diff --git a/.github/workflows/cross-build.yml b/.github/workflows/cross-build.yml index 14a36d9..4095a6f 100644 --- a/.github/workflows/cross-build.yml +++ b/.github/workflows/cross-build.yml @@ -7,12 +7,12 @@ # Copyright: (c) 2025-2026 HYPERI PTY LIMITED # # Trigger: - workflow_run after CI succeeds on main in this repository, never -# for a pull request (most common path - fires when -# hyperi-ci's publish step tags vX.Y.Z; the [skip ci] suffix on -# the chore commit blocks the tag-push trigger so we use the -# ci-completion event instead). -# - on tag push (vX.Y.Z) -> fallback for tags pushed without the -# [skip ci] suffix (manual tagging, foreign release tools, etc.). +# for a pull request. The Package workflow follows this run only. +# - on tag push (vX.Y.Z). hyperi-ci tags the released commit, which +# carries no [skip ci], so every release starts this run as well as +# the workflow_run one. Builds are queued per tag, and the second +# finds the first one's upload and writes nothing. The [skip ci] is +# on the untagged chore(release) commit that follows the tag. # - workflow_dispatch with `tag` input -> retroactive / out-of-band. # # Status: Windows builds are EXPERIMENTAL - best effort, not in the formal diff --git a/.hyperi-ci.yaml b/.hyperi-ci.yaml index 602b09e..3b8b057 100644 --- a/.hyperi-ci.yaml +++ b/.hyperi-ci.yaml @@ -9,9 +9,7 @@ # Reference: config/defaults.yaml in hyperi-ci. # Category 4, general OSS: public, Apache-2.0, publishes, neutral branding -# (no HyperI brand furniture). This records what the repo already does -- the -# LICENSE, the public visibility and `publish.target: oss` below were all -# already in place; only the declaration was missing. +# (no HyperI brand furniture). classification: oss language: rust @@ -29,6 +27,9 @@ build: rust: features: all targets: [] + optimize: + # A short-lived CLI that declares no jemalloc feature, so it builds on the system allocator. + allocator: system release: enabled: true channel: release diff --git a/docs/release.md b/docs/release.md index a2af431..a6dddcb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -66,17 +66,20 @@ checksums, `.deb` and `.rpm` via nfpm, the rendered Homebrew formula and Scoop manifest pushed into the sibling tap and bucket repos, and the static install scripts. -## Why the chain uses workflow_run, not tag pushes - -The obvious wiring -- trigger Cross-build on `push: tags: v*` -- does not -fire. semantic-release commits the version bump with a `[skip ci]` suffix, -and GitHub suppresses the tag-push event for those commits. Cross-build -therefore listens for CI COMPLETION instead, and derives the tag itself by -reading the most recent `v*` tag. - -The tag-push trigger is still declared as a fallback for tags created outside -semantic-release (manual tagging, a foreign release tool). Both paths land in -the same `resolve-tag` job. +## Why Cross-build has two triggers + +A release starts Cross-build twice. hyperi-ci tags the released commit, which +carries no `[skip ci]`, so the tag push starts a `push` run. It then commits +`VERSION` and `CHANGELOG.md` back as an untagged +`chore(release)` commit marked `[skip ci]`, and that mark only stops the commit +starting another CI run. The completion of the CI run starts the `workflow_run` +build, which derives the tag by reading the most recent `v*` tag. + +Both land in the same `resolve-tag` job. Builds are queued per tag and platform, +so the second one finds the first one's upload and writes nothing. Package +follows only the `workflow_run` build, so a release packages once. A tag pushed +outside hyperi-ci (manual tagging, a foreign release tool) starts the `push` +run alone. Because Package runs off Cross-build's completion rather than a shared artefact handoff, its linux tarball jobs POLL R2 for the raw binary before diff --git a/packaging/README.md b/packaging/README.md index fdb74f3..a6a9239 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -10,12 +10,13 @@ the rendered files. | Platform | Status | |---|---| | Linux (amd64, arm64) | Supported - built and tested in CI | -| macOS (amd64, arm64) | Templates ready; binary builds pending hyperi-ci darwin support | -| **Windows (amd64)** | **EXPERIMENTAL - best effort.** Not in the formal CI test path. install.ps1 template ships, but Windows binaries are not yet produced by hyperi-ci. Planned phase 2 via `cargo-zigbuild` cross-compilation | +| macOS (amd64, arm64) | Built by the Cross-build workflow and published with each release. Not tested in CI | +| **Windows (amd64)** | **EXPERIMENTAL - best effort.** Not in the formal CI test path. Cross-build compiles it with `cargo-zigbuild` and publishes it beside the other platforms. There is no `windows-arm64` build | -The Windows channel is provided so consumers can iterate, but it has -no CI gate behind it - expect rough edges until the formal test path -covers it. +The Windows channel is provided so consumers can iterate. Nothing runs +the Windows binary in CI, and `tests/install_ps1.rs` drives install.ps1 +under `pwsh` only on a host that has it - expect rough edges until the +formal test path covers it. ## What it produces @@ -39,7 +40,7 @@ sibling repos on the same org: These are populated per release by copying the rendered manifests in. -Both detect OS/arch, download the matching binary from the configured R2 download base (or any HTTP host), and install to a system or user directory. `install.sh` refuses to install unless the binary matches a published SHA-256 for that exact asset: a missing sums file, a sums file without the asset, a mismatch or a download error all exit non-zero. +Both detect OS/arch, download the matching binary from the configured R2 download base (or any HTTP host), and install to a system or user directory. Each refuses to install unless the binary matches a published SHA-256 for that exact asset: a missing sums file, a sums file without the asset, a mismatch or a download error all stop the install. `install.sh` exits non-zero. `install.ps1` throws, so run as a file it exits 1, and under `irm | iex` it leaves the caller's session open. Adding a new channel: drop a file into `packaging/templates/` or `packaging/templates-release/`, reference any `${PKG_*}` variable, @@ -84,7 +85,7 @@ ${PKG_DOWNLOAD_BASE}/${VERSION}/${PKG_BINARY}-${os}-${arch}.sha256 # checked fi ${PKG_DOWNLOAD_BASE}/${VERSION}/checksums.sha256 # fallback ``` -`install.sh` needs one of the two sums files to carry a ` ` line for the exact asset name. The first file that names the asset decides, so a mismatch there never falls through to the other. +Each installer needs one of the two sums files to carry a ` ` line for the exact asset name, case included. On Windows the asset is the `.exe`, and the per-platform sums file drops that extension: `-windows-.sha256` lists the `.exe` and the `.zip`. The first file that names the asset decides, so a mismatch there never falls through to the other. Where `${VERSION}` is either `latest` or a `vX.Y.Z` tag, and `os` is one of `linux`, `darwin`, `windows`, and `arch` is `amd64` or `arm64`. diff --git a/packaging/templates/install.ps1 b/packaging/templates/install.ps1 index d6a2106..a072fd9 100644 --- a/packaging/templates/install.ps1 +++ b/packaging/templates/install.ps1 @@ -27,6 +27,36 @@ $binary = '${PKG_BINARY}' $downloadBase = '${PKG_DOWNLOAD_BASE}' $defaultDir = "${PKG_WINDOWS_DEFAULT_DIR}" +# Every failure throws: `exit` would close the caller's session under `irm | iex`. +function Stop-Install([string]$Message) { + throw "install.ps1: $Message" +} + +# True once $From is saved to $To. Any HTTP or network error returns false. +function Save-Download([string]$From, [string]$To) { + try { + Invoke-WebRequest -UseBasicParsing -Uri $From -OutFile $To + return $true + } catch { + return $false + } +} + +# The SHA-256 that sums file $Path records for exactly the file named $Name, or nothing +# when that entry is missing, malformed or listed more than once. +function Get-RecordedSha256([string]$Path, [string]$Name) { + $hashes = @() + foreach ($line in @(Get-Content -LiteralPath $Path)) { + $fields = @($line.Trim() -split '\s+') + if ($fields.Count -eq 2 -and ($fields[1] -replace '^\*', '') -ceq $Name) { + $hashes += $fields[0] + } + } + if ($hashes.Count -eq 1 -and $hashes[0] -match '^[0-9a-fA-F]{64}$') { + return $hashes[0].ToLowerInvariant() + } +} + Write-Host "[experimental] Windows install is not yet in the formal CI test path." -ForegroundColor Yellow if ($Dir) { @@ -52,38 +82,40 @@ if ($Version -eq 'latest') { $versionPath = "v$Version" } -$asset = "$binary-windows-$arch.exe" -$url = "$downloadBase/$versionPath/$asset" -$sumsUrl = "$downloadBase/$versionPath/checksums.sha256" +$asset = "$binary-windows-$arch.exe" +$releaseUrl = "$downloadBase/$versionPath" +$platformSums = "$binary-windows-$arch.sha256" $tmpDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("$binary-install-" + [Guid]::NewGuid().Guid)) try { $tmpBinary = Join-Path $tmpDir "$binary.exe" Write-Host "Downloading $binary $versionPath (windows-$arch)..." - try { - Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $tmpBinary - } catch { - Write-Error "Download failed: $url`n$_" - exit 1 + if (-not (Save-Download "$releaseUrl/$asset" $tmpBinary)) { + Stop-Install "download failed: $releaseUrl/$asset" } - # Checksum verify if available - $sumsFile = Join-Path $tmpDir 'checksums.sha256' - try { - Invoke-WebRequest -UseBasicParsing -Uri $sumsUrl -OutFile $sumsFile -ErrorAction Stop - $line = Select-String -Path $sumsFile -Pattern ([regex]::Escape($asset)) | Select-Object -First 1 - if ($line) { - $expected = ($line.Line -split '\s+')[0] - $actual = (Get-FileHash -Algorithm SHA256 -Path $tmpBinary).Hash.ToLower() - if ($expected.ToLower() -ne $actual) { - Write-Error "Checksum mismatch for $asset`n expected: $expected`n actual: $actual" - exit 1 - } - Write-Host "Checksum verified." + # Every release publishes -windows-.sha256 (the .exe and the .zip), and + # checksums.sha256 is the older combined file. The first one that names this asset + # decides, so a mismatch never falls through. + $expected = $null + foreach ($sums in @($platformSums, 'checksums.sha256')) { + $sumsFile = Join-Path $tmpDir $sums + if (Save-Download "$releaseUrl/$sums" $sumsFile) { + $expected = Get-RecordedSha256 $sumsFile $asset } - } catch { - # checksums file is optional + if ($expected) { + break + } + } + if (-not $expected) { + Stop-Install "no SHA-256 for $asset in $releaseUrl/$platformSums or $releaseUrl/checksums.sha256, refusing to install an unverified binary" + } + + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $tmpBinary).Hash.ToLowerInvariant() + if ($expected -cne $actual) { + Stop-Install "checksum mismatch for $asset, refusing to install (expected: $expected, actual: $actual)" } + Write-Host "Checksum verified: sha256 $actual" if (-not (Test-Path $installDir)) { New-Item -ItemType Directory -Path $installDir -Force | Out-Null diff --git a/tests/common/mod.rs b/tests/common/mod.rs index c168487..48d64c1 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,6 +1,6 @@ // Project: macbash // File: tests/common/mod.rs -// Purpose: Shared helpers for the end-to-end CLI integration tests +// Purpose: Shared helpers for the end-to-end CLI and installer integration tests // Language: Rust // // License: Apache-2.0 @@ -10,11 +10,22 @@ // used by only one of them would otherwise be reported as dead code. #![allow(dead_code)] +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; use assert_cmd::Command; use tempfile::TempDir; +/// The release tag the loopback download host serves its files under. +pub const VERSION: &str = "v9.9.9"; + +/// A SHA-256 that no fixture binary has. +pub const OTHER_SHA256: &str = "14b1f81dd672550d0055c981ed42e01aa0c33590e09460468f890cb4cfa734b9"; + /// A script with no findings at any severity -- the "already portable" case. pub const PORTABLE_SCRIPT: &str = "#!/usr/bin/env bash\nset -euo pipefail\n\nprintf '%s\\n' \"hello\"\n"; @@ -125,3 +136,75 @@ pub fn json_severity_count(json: &str, severity: &str) -> usize { json.matches(&format!("\"severity\": \"{severity}\"")) .count() } + +/// What the download host serves under the version directory, keyed by file name. +#[derive(Default)] +pub struct Release { + files: HashMap, +} + +impl Release { + pub fn file(mut self, name: &str, body: impl Into) -> Self { + self.files.insert(name.to_string(), body.into()); + self + } +} + +/// Serve `release` over HTTP on a loopback port and return the download base URL. +pub fn serve(release: Release) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("binding a loopback port"); + let base = format!( + "http://{}", + listener.local_addr().expect("reading the bound port") + ); + let files: HashMap = release + .files + .into_iter() + .map(|(name, body)| (format!("/{VERSION}/{name}"), body)) + .collect(); + thread::spawn(move || { + for stream in listener.incoming().flatten() { + respond(stream, &files); + } + }); + base +} + +fn respond(mut stream: TcpStream, files: &HashMap) { + // A client that connects and never sends a request must not wedge the server. + let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); + let Ok(clone) = stream.try_clone() else { + return; + }; + let mut reader = BufReader::new(clone); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + loop { + let mut header = String::new(); + match reader.read_line(&mut header) { + Ok(0) | Err(_) => break, + Ok(_) if header.trim_end().is_empty() => break, + Ok(_) => {} + } + } + let path = request_line.split_whitespace().nth(1).unwrap_or_default(); + let (status, body) = match files.get(path) { + Some(body) => ("200 OK", body.as_str()), + None => ("404 Not Found", "not found\n"), + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); +} + +/// The first `tool` on PATH, if any. +pub fn which(tool: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(tool)) + .find(|candidate| candidate.is_file()) +} diff --git a/tests/install_ps1.rs b/tests/install_ps1.rs new file mode 100644 index 0000000..c85609b --- /dev/null +++ b/tests/install_ps1.rs @@ -0,0 +1,265 @@ +// Project: macbash +// File: tests/install_ps1.rs +// Purpose: End-to-end tests for the checksum gate in the rendered install.ps1 +// Language: Rust +// +// License: Apache-2.0 +// Copyright: (c) 2026 HYPERI PTY LIMITED + +// install.ps1 runs under PowerShell 7 (`pwsh`), which also runs on Linux and macOS. +// A host without pwsh cannot run it, so each test says so and returns. + +mod common; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use assert_cmd::Command; +use assert_cmd::assert::Assert; +use predicates::prelude::*; +use tempfile::TempDir; + +use common::{OTHER_SHA256, Release, VERSION, serve, which}; + +/// The asset install.ps1 asks for, and the sums file the release pipeline writes for it. +const ASSET: &str = "macbash-windows-amd64.exe"; +const PLATFORM_SUMS: &str = "macbash-windows-amd64.sha256"; + +/// The binary the test host publishes, and its SHA-256. install.ps1 never runs it. +const EXE: &str = "MZ macbash.exe test fixture\n"; +const EXE_SHA256: &str = "b90fb180176de8051be70ed5d74ef117ea44f1d5da5246d0f9a7a5e284037646"; + +fn release_with_exe() -> Release { + Release::default().file(ASSET, EXE) +} + +/// A sums file laid out the way the release pipeline writes it: the .exe, then the zip. +fn platform_sums(hash: &str) -> String { + format!("{hash} {ASSET}\n{OTHER_SHA256} macbash-windows-amd64.zip\n") +} + +/// install.ps1 rendered against `base`, as packaging/render.sh would, written into `dir`. +fn render(dir: &TempDir, base: &str) -> PathBuf { + let template_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("packaging/templates/install.ps1"); + let template = + std::fs::read_to_string(&template_path).expect("reading the install.ps1 template"); + let bin_dir = install_dir(dir); + let bin = bin_dir.to_str().expect("tempdir paths are utf-8"); + let rendered = template + .replace("${PKG_NAME}", "macbash") + .replace("${PKG_BINARY}", "macbash") + .replace("${PKG_DOWNLOAD_BASE}", base) + .replace("${PKG_WINDOWS_DEFAULT_DIR}", bin); + assert!( + !rendered.contains("${PKG_"), + "install.ps1 uses a placeholder these tests do not render" + ); + let script = dir.path().join("install.ps1"); + std::fs::write(&script, rendered).expect("writing the rendered install.ps1"); + script +} + +fn install_dir(dir: &TempDir) -> PathBuf { + dir.path().join("bin") +} + +fn installed(dir: &TempDir) -> PathBuf { + install_dir(dir).join("macbash.exe") +} + +/// Run the installer against `release`, or `None` when this host has no pwsh. +fn install(release: Release) -> Option<(TempDir, Assert)> { + let Some(pwsh) = which("pwsh") else { + eprintln!("skipping: pwsh is not on PATH, so install.ps1 was not run"); + return None; + }; + let dir = tempfile::tempdir().expect("creating a tempdir"); + let home = dir.path().join("home"); + let tmp = dir.path().join("tmp"); + std::fs::create_dir(&home).expect("creating HOME"); + std::fs::create_dir(&tmp).expect("creating the temp dir"); + let script = render(&dir, &serve(release)); + // install.ps1 reads TEMP and PROCESSOR_ARCHITECTURE, which only Windows sets. The + // proxy bypass keeps a caller's proxy settings off the loopback download, and + // NO_COLOR keeps ANSI escapes out of the error text the tests match. + let run = Command::new(pwsh) + .args(["-NoProfile", "-NonInteractive", "-File"]) + .arg(&script) + .arg("-Dir") + .arg(install_dir(&dir)) + .args(["-Version", VERSION]) + .env("HOME", &home) + .env("TMPDIR", &tmp) + .env("TEMP", &tmp) + .env("PROCESSOR_ARCHITECTURE", "AMD64") + .env("NO_PROXY", "127.0.0.1") + .env("no_proxy", "127.0.0.1") + .env("NO_COLOR", "1") + .env("POWERSHELL_TELEMETRY_OPTOUT", "1") + .env("POWERSHELL_UPDATECHECK", "Off") + .timeout(Duration::from_secs(120)) + .assert(); + Some((dir, run)) +} + +/// PowerShell word-wraps an error to the console width and prefixes each continued line, +/// so a message is matched with its whitespace and wrap markers removed. +fn squashed(text: &str) -> String { + text.chars() + .filter(|c| !c.is_whitespace() && *c != '|') + .collect() +} + +fn stderr_says(message: &str) -> impl Predicate<[u8]> { + let wanted = squashed(message); + predicate::function(move |stderr: &[u8]| { + squashed(&String::from_utf8_lossy(stderr)).contains(&wanted) + }) +} + +fn assert_not_installed(dir: &TempDir) { + assert!( + !installed(dir).exists(), + "install.ps1 must not install a binary it could not verify" + ); +} + +/// The installer found no usable SHA-256 for the asset and refused. +fn assert_no_sha256(run: Assert) { + run.code(1) + .stderr(stderr_says( + "install.ps1: no SHA-256 for macbash-windows-amd64.exe", + )) + .stderr(stderr_says("refusing to install an unverified binary")); +} + +#[test] +fn installs_when_the_platform_checksum_matches() { + let release = release_with_exe().file(PLATFORM_SUMS, platform_sums(EXE_SHA256)); + let Some((dir, run)) = install(release) else { + return; + }; + run.code(0).stdout(predicate::str::contains(format!( + "Checksum verified: sha256 {EXE_SHA256}" + ))); + let body = std::fs::read_to_string(installed(&dir)).expect("the binary is installed"); + assert_eq!(body, EXE); +} + +#[test] +fn installs_from_the_combined_sums_file_when_no_platform_file_exists() { + let combined = format!("{EXE_SHA256} {ASSET}\n"); + let Some((dir, run)) = install(release_with_exe().file("checksums.sha256", combined)) else { + return; + }; + run.code(0).stdout(predicate::str::contains(format!( + "Checksum verified: sha256 {EXE_SHA256}" + ))); + assert!(installed(&dir).exists()); +} + +#[test] +fn installs_when_the_sums_file_marks_the_entry_binary() { + // `sha256sum -b` and `shasum -b` write ` *`. + let release = release_with_exe().file(PLATFORM_SUMS, format!("{EXE_SHA256} *{ASSET}\n")); + let Some((dir, run)) = install(release) else { + return; + }; + run.code(0); + assert!(installed(&dir).exists()); +} + +#[test] +fn refuses_when_no_sums_file_is_published() { + let Some((dir, run)) = install(release_with_exe()) else { + return; + }; + assert_no_sha256(run); + assert_not_installed(&dir); +} + +#[test] +fn refuses_when_the_sums_files_do_not_name_this_asset() { + // The binary's real hash sits against the zip's name, so matching the asset name as a + // substring of a line would wrongly accept it. + let release = release_with_exe() + .file( + PLATFORM_SUMS, + format!("{EXE_SHA256} macbash-windows-amd64.zip\n"), + ) + .file( + "checksums.sha256", + format!("{EXE_SHA256} macbash-linux-amd64\n"), + ); + let Some((dir, run)) = install(release) else { + return; + }; + assert_no_sha256(run); + assert_not_installed(&dir); +} + +#[test] +fn refuses_an_asset_named_in_another_case() { + // PowerShell compares strings case-insensitively unless told otherwise. + let release = release_with_exe().file( + PLATFORM_SUMS, + format!("{EXE_SHA256} MACBASH-WINDOWS-AMD64.EXE\n"), + ); + let Some((dir, run)) = install(release) else { + return; + }; + assert_no_sha256(run); + assert_not_installed(&dir); +} + +#[test] +fn refuses_a_checksum_mismatch_even_when_the_combined_file_would_match() { + let release = release_with_exe() + .file(PLATFORM_SUMS, platform_sums(OTHER_SHA256)) + .file("checksums.sha256", format!("{EXE_SHA256} {ASSET}\n")); + let Some((dir, run)) = install(release) else { + return; + }; + run.code(1) + .stderr(stderr_says( + "install.ps1: checksum mismatch for macbash-windows-amd64.exe", + )) + .stderr(stderr_says(&format!("expected: {OTHER_SHA256}"))) + .stderr(stderr_says(&format!("actual: {EXE_SHA256}"))); + assert_not_installed(&dir); +} + +#[test] +fn refuses_an_asset_listed_twice() { + let release = release_with_exe().file( + PLATFORM_SUMS, + format!("{EXE_SHA256} {ASSET}\n{OTHER_SHA256} {ASSET}\n"), + ); + let Some((dir, run)) = install(release) else { + return; + }; + assert_no_sha256(run); + assert_not_installed(&dir); +} + +#[test] +fn refuses_a_hash_that_is_not_64_hex_digits() { + let release = release_with_exe().file(PLATFORM_SUMS, format!("b90fb180 {ASSET}\n")); + let Some((dir, run)) = install(release) else { + return; + }; + assert_no_sha256(run); + assert_not_installed(&dir); +} + +#[test] +fn refuses_when_the_binary_download_fails() { + let release = Release::default().file(PLATFORM_SUMS, platform_sums(EXE_SHA256)); + let Some((dir, run)) = install(release) else { + return; + }; + run.code(1) + .stderr(stderr_says("install.ps1: download failed:")); + assert_not_installed(&dir); +} diff --git a/tests/install_sh.rs b/tests/install_sh.rs index dbcfd38..a86c3f8 100644 --- a/tests/install_sh.rs +++ b/tests/install_sh.rs @@ -9,12 +9,10 @@ // install.sh is a POSIX sh script driven by curl, so it is only tested on unix. #![cfg(unix)] -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; -use std::net::{TcpListener, TcpStream}; +mod common; + use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use std::thread; use std::time::Duration; use assert_cmd::Command; @@ -22,15 +20,12 @@ use assert_cmd::assert::Assert; use predicates::prelude::*; use tempfile::TempDir; -const VERSION: &str = "v9.9.9"; +use common::{OTHER_SHA256, Release, VERSION, serve, which}; /// The binary the test host publishes, and its SHA-256. const BINARY: &str = "#!/bin/sh\necho 'macbash 9.9.9 (test fixture)'\n"; const BINARY_SHA256: &str = "7146ea8f8b6070cbac667c0a82b4f1262ea9520a5a6659ed36dcb6852ebc2753"; -/// The SHA-256 of `#!/bin/sh\necho 'not the published binary'\n`. -const OTHER_SHA256: &str = "14b1f81dd672550d0055c981ed42e01aa0c33590e09460468f890cb4cfa734b9"; - /// Every asset install.sh can ask for, so the same release serves any unix host. const ASSETS: [&str; 4] = [ "macbash-linux-amd64", @@ -44,12 +39,6 @@ const BASE_TOOLS: [&str; 11] = [ "awk", "chmod", "curl", "dirname", "head", "mkdir", "mktemp", "mv", "rm", "tr", "uname", ]; -/// What the download host serves under the version directory, keyed by file name. -#[derive(Default)] -struct Release { - files: HashMap, -} - impl Release { fn with_binaries() -> Self { ASSETS.iter().fold(Self::default(), |release, asset| { @@ -57,11 +46,6 @@ impl Release { }) } - fn file(mut self, name: &str, body: impl Into) -> Self { - self.files.insert(name.to_string(), body.into()); - self - } - /// A `.sha256` per asset, laid out the way the release pipeline writes them. fn per_asset_sums(self, hash: &str) -> Self { ASSETS.iter().fold(self, |release, asset| { @@ -71,57 +55,6 @@ impl Release { } } -/// Serve `release` over HTTP on a loopback port and return the download base URL. -fn serve(release: Release) -> String { - let listener = TcpListener::bind("127.0.0.1:0").expect("binding a loopback port"); - let base = format!( - "http://{}", - listener.local_addr().expect("reading the bound port") - ); - let files: HashMap = release - .files - .into_iter() - .map(|(name, body)| (format!("/{VERSION}/{name}"), body)) - .collect(); - thread::spawn(move || { - for stream in listener.incoming().flatten() { - respond(stream, &files); - } - }); - base -} - -fn respond(mut stream: TcpStream, files: &HashMap) { - // A client that connects and never sends a request must not wedge the server. - let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); - let Ok(clone) = stream.try_clone() else { - return; - }; - let mut reader = BufReader::new(clone); - let mut request_line = String::new(); - if reader.read_line(&mut request_line).is_err() { - return; - } - loop { - let mut header = String::new(); - match reader.read_line(&mut header) { - Ok(0) | Err(_) => break, - Ok(_) if header.trim_end().is_empty() => break, - Ok(_) => {} - } - } - let path = request_line.split_whitespace().nth(1).unwrap_or_default(); - let (status, body) = match files.get(path) { - Some(body) => ("200 OK", body.as_str()), - None => ("404 Not Found", "not found\n"), - }; - let response = format!( - "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - let _ = stream.write_all(response.as_bytes()); -} - /// install.sh rendered against `base`, as packaging/render.sh would, written into `dir`. fn render(dir: &TempDir, base: &str) -> PathBuf { let template_path = @@ -153,13 +86,6 @@ fn installed(dir: &TempDir) -> PathBuf { install_dir(dir).join("macbash") } -fn which(tool: &str) -> Option { - let path = std::env::var_os("PATH")?; - std::env::split_paths(&path) - .map(|dir| dir.join(tool)) - .find(|candidate| candidate.is_file()) -} - /// A PATH holding only the base tools plus `hashers`, linked from the real PATH. fn restricted_path(dir: &TempDir, hashers: &[&str]) -> String { let tools = dir.path().join("tools");