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");