diff --git a/.github/workflows/cross-build.yml b/.github/workflows/cross-build.yml index 2a84dc3..14a36d9 100644 --- a/.github/workflows/cross-build.yml +++ b/.github/workflows/cross-build.yml @@ -6,7 +6,8 @@ # License: Apache-2.0 # Copyright: (c) 2025-2026 HYPERI PTY LIMITED # -# Trigger: - workflow_run after CI succeeds (most common path - fires when +# 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). @@ -26,6 +27,8 @@ name: Cross-build workflow_run: workflows: [CI] types: [completed] + # A fork's branch can also be called main, so resolve-tag checks the repository too. + branches: [main] workflow_dispatch: inputs: tag: @@ -39,14 +42,21 @@ permissions: jobs: resolve-tag: # workflow_run gives us no inputs and `github.ref_name` resolves to the - # default branch, so derive the tag explicitly. Skips when CI failed or - # when CI didn't produce a new tag (non-publish run). + # default branch, so derive the tag explicitly. + # + # A workflow_run job holds the publish secrets whatever started the CI run, + # and CI also runs on fork pull requests. Only a successful CI run on main in + # this repository, from a push or a maintainer's dispatch, may start a build. name: Resolve tag to build runs-on: ubuntu-latest if: >- ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' - || github.event.workflow_run.conclusion == 'success' }} + || (github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.head_branch == 'main' + && github.event.workflow_run.head_repository.full_name == github.repository + && (github.event.workflow_run.event == 'push' + || github.event.workflow_run.event == 'workflow_dispatch')) }} outputs: tag: ${{ steps.pick.outputs.tag }} steps: @@ -75,11 +85,9 @@ jobs: tag="$REF_NAME" ;; workflow_run) - # CI completed successfully. Pick the most recent v-tag - if - # CI was a publish run it will have created vX.Y.Z; if it was a - # validate-only run the latest tag is unchanged and rebuilding - # is idempotent (same source -> same bytes overwrite the same R2 - # paths). + # Pick the most recent v-tag. A publish run has just created it. + # After any other run it is already published, and the upload + # step finds it there and writes nothing. git fetch --tags --force origin tag=$(git tag --sort=-creatordate --list 'v*' | head -1) if [ -z "$tag" ]; then @@ -97,6 +105,11 @@ jobs: needs: resolve-tag if: ${{ needs.resolve-tag.outputs.tag != '' }} runs-on: ${{ matrix.runner }} + # The tag push and the CI completion can both build one tag. Queued rather + # than raced, the second build finds the first one's upload and writes nothing. + concurrency: + group: cross-build-${{ needs.resolve-tag.outputs.tag }}-${{ matrix.os_arch }} + cancel-in-progress: false strategy: fail-fast: false matrix: @@ -256,14 +269,27 @@ jobs: R2_ENDPOINT: https://98d20454e2af7a9397ad9366a1641659.r2.cloudflarestorage.com R2_BUCKET: bin-repo TAG: ${{ steps.ver.outputs.tag }} + OS_ARCH: ${{ matrix.os_arch }} shell: bash run: | set -euo pipefail - dest_versioned="s3://${R2_BUCKET}/macbash/${TAG}/" - dest_latest="s3://${R2_BUCKET}/macbash/latest/" - for f in dist/*; do - for dest in "$dest_versioned" "$dest_latest"; do - aws s3 cp --endpoint-url "$R2_ENDPOINT" "$f" "$dest" + # Versioned paths are write-once: the formula and install.sh pin these + # bytes by sha256. The .sha256 goes up last, so it marks a full upload. + sums="dist/macbash-${OS_ARCH}.sha256" + marker="macbash/${TAG}/macbash-${OS_ARCH}.sha256" + published=$(aws s3api list-objects-v2 --endpoint-url "$R2_ENDPOINT" --bucket "$R2_BUCKET" \ + --prefix "$marker" --query "length(Contents || \`[]\`)" --output text) + case "$published" in + 0) ;; + 1) echo "::notice::${marker} already exists and versioned artefacts are write-once, so nothing was uploaded"; exit 0 ;; + *) echo "::error::unexpected R2 listing for ${marker}: ${published}"; exit 1 ;; + esac + for dest in "s3://${R2_BUCKET}/macbash/${TAG}/" "s3://${R2_BUCKET}/macbash/latest/"; do + for f in dist/*; do + if [ "$f" != "$sums" ]; then + aws s3 cp --endpoint-url "$R2_ENDPOINT" "$f" "$dest" + fi done + aws s3 cp --endpoint-url "$R2_ENDPOINT" "$sums" "$dest" done echo "Published to https://downloads.hyperi.io/macbash/${TAG}/" diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index f7713d6..8ba35b2 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -8,7 +8,8 @@ # License: Apache-2.0 # Copyright: (c) 2025-2026 HYPERI PTY LIMITED # -# Trigger: - workflow_run after Cross-build succeeds (the standard path) +# Trigger: - workflow_run after Cross-build succeeds on main in this +# repository (the standard path) # - workflow_dispatch with tag input (manual fallback) # # On workflow_run the tag is derived from the most recent v* tag, @@ -25,6 +26,8 @@ name: Package workflow_run: workflows: [Cross-build] types: [completed] + # A fork's branch can also be called main, so resolve-tag checks the repository too. + branches: [main] workflow_dispatch: inputs: tag: @@ -44,11 +47,18 @@ jobs: resolve-tag: # workflow_run gives us no inputs - derive the tag from the latest v* tag, # same pattern as cross-build.yml's resolve-tag job. + # + # Same gate as cross-build.yml. A Cross-build whose own gate refused a fork's + # CI run concludes "skipped", never "success", so it cannot start this one. name: Resolve tag to package runs-on: ubuntu-latest if: >- ${{ github.event_name == 'workflow_dispatch' - || github.event.workflow_run.conclusion == 'success' }} + || (github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.head_branch == 'main' + && github.event.workflow_run.head_repository.full_name == github.repository + && (github.event.workflow_run.event == 'workflow_run' + || github.event.workflow_run.event == 'workflow_dispatch')) }} outputs: tag: ${{ steps.pick.outputs.tag }} steps: @@ -136,17 +146,28 @@ jobs: env: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + ARCH: ${{ matrix.arch }} shell: bash run: | set -euo pipefail aws configure set aws_access_key_id "$R2_ACCESS_KEY_ID" aws configure set aws_secret_access_key "$R2_SECRET_ACCESS_KEY" aws configure set region auto + # Versioned paths are write-once: the formula and install.sh pin these + # bytes by sha256. The .sha256 goes up last, so it marks a full upload. + marker="macbash/${TAG}/macbash-linux-${ARCH}.sha256" + published=$(aws s3api list-objects-v2 --endpoint-url "$R2_ENDPOINT" --bucket "$R2_BUCKET" \ + --prefix "$marker" --query "length(Contents || \`[]\`)" --output text) + case "$published" in + 0) ;; + 1) echo "::notice::${marker} already exists and versioned artefacts are write-once, so nothing was uploaded"; exit 0 ;; + *) echo "::error::unexpected R2 listing for ${marker}: ${published}"; exit 1 ;; + esac for dest in "s3://${R2_BUCKET}/macbash/${TAG}/" "s3://${R2_BUCKET}/macbash/latest/"; do aws s3 cp --endpoint-url "$R2_ENDPOINT" \ - "dist/macbash-linux-${{ matrix.arch }}.tar.gz" "$dest" + "dist/macbash-linux-${ARCH}.tar.gz" "$dest" aws s3 cp --endpoint-url "$R2_ENDPOINT" \ - "dist/macbash-linux-${{ matrix.arch }}.sha256" "$dest" + "dist/macbash-linux-${ARCH}.sha256" "$dest" done deb-rpm: @@ -200,23 +221,37 @@ jobs: nfpm package -f packaging/dist/release/nfpm.yaml -p deb -t out/ nfpm package -f packaging/dist/release/nfpm.yaml -p rpm -t out/ ls -la out - # Per-arch sha for the packages - ( cd out && shasum -a 256 ./* ) > "out/macbash-linux-${{ matrix.arch }}.packages.sha256" + # Per-arch sha for the packages. Named, not ./*, which would list the sums file itself. + ( cd out && shasum -a 256 ./*.deb ./*.rpm ) > "out/macbash-linux-${{ matrix.arch }}.packages.sha256" - name: Upload to R2 env: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + ARCH: ${{ matrix.arch }} shell: bash run: | set -euo pipefail aws configure set aws_access_key_id "$R2_ACCESS_KEY_ID" aws configure set aws_secret_access_key "$R2_SECRET_ACCESS_KEY" aws configure set region auto + # Write-once, as for the tarballs. The packages .sha256 goes up last. + sums="out/macbash-linux-${ARCH}.packages.sha256" + marker="macbash/${TAG}/macbash-linux-${ARCH}.packages.sha256" + published=$(aws s3api list-objects-v2 --endpoint-url "$R2_ENDPOINT" --bucket "$R2_BUCKET" \ + --prefix "$marker" --query "length(Contents || \`[]\`)" --output text) + case "$published" in + 0) ;; + 1) echo "::notice::${marker} already exists and versioned artefacts are write-once, so nothing was uploaded"; exit 0 ;; + *) echo "::error::unexpected R2 listing for ${marker}: ${published}"; exit 1 ;; + esac for dest in "s3://${R2_BUCKET}/macbash/${TAG}/" "s3://${R2_BUCKET}/macbash/latest/"; do for f in out/*; do - aws s3 cp --endpoint-url "$R2_ENDPOINT" "$f" "$dest" + if [ "$f" != "$sums" ]; then + aws s3 cp --endpoint-url "$R2_ENDPOINT" "$f" "$dest" + fi done + aws s3 cp --endpoint-url "$R2_ENDPOINT" "$sums" "$dest" done manifests: @@ -255,27 +290,40 @@ jobs: aws configure set aws_secret_access_key "$R2_SECRET_ACCESS_KEY" aws configure set region auto + # Use text/plain content-type so curl/iex render in browser too. + put_script() { + local ct="text/x-shellscript" + case "$1" in *.ps1) ct="text/plain" ;; esac + aws s3 cp --endpoint-url "$R2_ENDPOINT" --content-type "$ct" \ + "packaging/dist/$1" "$2" + } + + # Static install scripts at /macbash/install.sh (curl-pipe target), + # always re-rendered from the default branch. + for f in install.sh install.ps1 uninstall.sh uninstall.ps1; do + put_script "$f" "s3://${R2_BUCKET}/macbash/$f" + done + + # The per-version copies are write-once, as for the binaries: pinning a + # tag-specific install.sh URL gets the script that shipped with that + # binary. /install.sh goes up last, so it marks a full upload. + marker="macbash/${TAG}/install.sh" + published=$(aws s3api list-objects-v2 --endpoint-url "$R2_ENDPOINT" --bucket "$R2_BUCKET" \ + --prefix "$marker" --query "length(Contents || \`[]\`)" --output text) + case "$published" in + 0) ;; + 1) echo "::notice::${marker} already exists and versioned artefacts are write-once, so only the root install scripts were uploaded"; exit 0 ;; + *) echo "::error::unexpected R2 listing for ${marker}: ${published}"; exit 1 ;; + esac + # Per-version manifests under //manifests/ + /latest/manifests/ for dest in "s3://${R2_BUCKET}/macbash/${TAG}/manifests/" \ "s3://${R2_BUCKET}/macbash/latest/manifests/"; do aws s3 cp --recursive --endpoint-url "$R2_ENDPOINT" \ packaging/dist/release/ "$dest" done - - # Static install scripts at /macbash/install.sh (curl-pipe target). - # Also mirror to per-version path for reproducibility - pinning a - # tag-specific install.sh URL lets users install a known-good copy - # of the script that downloaded the same-version binary. - for f in install.sh install.ps1 uninstall.sh uninstall.ps1; do - for dest in "s3://${R2_BUCKET}/macbash/$f" \ - "s3://${R2_BUCKET}/macbash/${TAG}/$f"; do - # Use text/plain content-type so curl/iex render in browser too. - ct="text/x-shellscript" - case "$f" in *.ps1) ct="text/plain" ;; esac - aws s3 cp --endpoint-url "$R2_ENDPOINT" \ - --content-type "$ct" \ - "packaging/dist/$f" "$dest" - done + for f in uninstall.ps1 uninstall.sh install.ps1 install.sh; do + put_script "$f" "s3://${R2_BUCKET}/macbash/${TAG}/$f" done publish-tap-bucket: diff --git a/action.yml b/action.yml index d9d53a4..87ba6da 100644 --- a/action.yml +++ b/action.yml @@ -50,7 +50,8 @@ runs: env: MACBASH_VERSION: ${{ inputs.version }} # Risk accepted: install.sh comes over TLS from the host that serves the - # binary, so fetching it first adds no integrity check; #10 tracks one. + # binary. It refuses any binary without a matching published SHA-256, but + # that sums file comes from the same host and nothing is signed. # nosemgrep: yaml.github-actions.security.gha-curl-pipe-shell.gha-curl-pipe-shell run: | set -euo pipefail diff --git a/build.rs b/build.rs index 4ded562..6ba5675 100644 --- a/build.rs +++ b/build.rs @@ -7,31 +7,41 @@ // Copyright: (c) 2025-2026 HYPERI PTY LIMITED use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; fn main() { - let sha = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_else(|| "unknown".into()); + let sha = git(&["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".into()); println!("cargo:rustc-env=VERGEN_GIT_SHA={sha}"); - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - println!( - "cargo:rustc-env=VERGEN_BUILD_TIMESTAMP={}", - rfc3339_utc(secs) - ); + // Never the clock: the same commit must always build the same bytes. + let built = build_epoch() + .map(rfc3339_utc) + .unwrap_or_else(|| "unknown".into()); + println!("cargo:rustc-env=VERGEN_BUILD_TIMESTAMP={built}"); + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); println!("cargo:rerun-if-changed=.git/HEAD"); println!("cargo:rerun-if-changed=.git/refs"); } +fn git(args: &[&str]) -> Option { + Command::new("git") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) +} + +/// SOURCE_DATE_EPOCH when the builder sets it, else the commit time of HEAD. +fn build_epoch() -> Option { + match std::env::var("SOURCE_DATE_EPOCH") { + Ok(raw) => Some(raw.trim().parse().unwrap_or_else(|_| { + panic!("SOURCE_DATE_EPOCH must be whole seconds since the Unix epoch, got {raw:?}") + })), + Err(_) => git(&["log", "-1", "--format=%ct", "HEAD"])?.parse().ok(), + } +} + fn rfc3339_utc(secs: u64) -> String { let days = (secs / 86_400) as i64; let tod = secs % 86_400; diff --git a/docs/release.md b/docs/release.md index a10c8d2..01b42e0 100644 --- a/docs/release.md +++ b/docs/release.md @@ -83,6 +83,18 @@ artefact handoff, its linux tarball jobs POLL R2 for the raw binary before proceeding. That tolerates hyperi-ci's publish step being slower than the cross-build. +## What may start a publish + +A `workflow_run` job holds the R2 and GitHub App secrets whatever started the upstream run, and CI also runs on pull requests from forks. So both `workflow_run` triggers filter on `branches: [main]`, and each `resolve-tag` job also requires the upstream run to have succeeded in this repository, from a push or a dispatch. A fork can name its branch `main`, which is why the repository check is there as well. + +A Cross-build that refuses a run concludes `skipped`, and Package only follows a `success`, so the refusal stops the whole chain. + +## Versioned paths are write-once + +The Homebrew formula and `install.sh` pin the bytes at `//` by SHA-256, so nothing overwrites them. Each upload checks R2 for the file it writes last (its `.sha256`, or `/install.sh` for the manifests), and when that exists it writes nothing, `latest/` included. The root `install.sh` is the exception: it is re-rendered from the default branch on every Package run. A re-run, or a CI run that published nothing new, therefore leaves the release alone. Replacing a published artefact means deleting it from R2 by hand first. + +The binaries carry no build clock either: `build.rs` stamps `SOURCE_DATE_EPOCH` when it is set, else the commit time of HEAD. + ## Versioning semantic-release owns the version, on its default conventional-commit rules: diff --git a/packaging/README.md b/packaging/README.md index 9fddc1c..fdb74f3 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -39,9 +39,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), verify a SHA-256 checksum if -available, and install to a system or user directory. +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. Adding a new channel: drop a file into `packaging/templates/` or `packaging/templates-release/`, reference any `${PKG_*}` variable, @@ -80,11 +78,14 @@ framework changes required. The rendered installers expect binaries at this URL layout: ```text -${PKG_DOWNLOAD_BASE}/${VERSION}/${PKG_BINARY}-${os}-${arch} # unix -${PKG_DOWNLOAD_BASE}/${VERSION}/${PKG_BINARY}-${os}-${arch}.exe # windows -${PKG_DOWNLOAD_BASE}/${VERSION}/checksums.sha256 # optional +${PKG_DOWNLOAD_BASE}/${VERSION}/${PKG_BINARY}-${os}-${arch} # unix +${PKG_DOWNLOAD_BASE}/${VERSION}/${PKG_BINARY}-${os}-${arch}.exe # windows +${PKG_DOWNLOAD_BASE}/${VERSION}/${PKG_BINARY}-${os}-${arch}.sha256 # checked first +${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. + 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.sh b/packaging/templates/install.sh index 5de7247..76f1258 100644 --- a/packaging/templates/install.sh +++ b/packaging/templates/install.sh @@ -15,6 +15,11 @@ set -eu +die() { + echo "install.sh: $*" >&2 + exit 1 +} + BINARY="${PKG_BINARY}" DOWNLOAD_BASE="${PKG_DOWNLOAD_BASE}" INSTALL_DIR="${PKG_DEFAULT_DIR}" @@ -65,42 +70,72 @@ else fi ASSET="$BINARY-$OS-$ARCH" -URL="$DOWNLOAD_BASE/$VERSION_PATH/$ASSET" -SUMS_URL="$DOWNLOAD_BASE/$VERSION_PATH/checksums.sha256" +RELEASE_URL="$DOWNLOAD_BASE/$VERSION_PATH" + +# Nothing installs unverified, so a host that cannot hash stops before downloading. +if command -v sha256sum >/dev/null 2>&1; then + HASHER=sha256sum +elif command -v shasum >/dev/null 2>&1; then + HASHER=shasum +elif command -v openssl >/dev/null 2>&1; then + HASHER=openssl +else + die "no SHA-256 tool found (need sha256sum, shasum or openssl), refusing to install an unverified binary" +fi + +sha256_of() { + case "$HASHER" in + sha256sum) sha256sum "$1" ;; + shasum) shasum -a 256 "$1" ;; + openssl) openssl dgst -sha256 -r "$1" ;; + esac | awk '{ print tolower($1) }' +} + +# HTTP errors fail the download, and a redirect may not leave HTTPS. +fetch() { + curl -fsSL --proto-redir '=https' --tlsv1.2 --connect-timeout 30 -o "$2" "$1" +} + +# The SHA-256 that sums file $1 records for exactly the file named $2, or nothing +# when that entry is missing, malformed or listed more than once. +sum_for() { + awk -v name="$2" ' + { file = $2; sub(/^\*/, "", file) } + NF == 2 && file == name { count++; hash = tolower($1) } + END { if (count == 1 && length(hash) == 64 && hash !~ /[^0-9a-f]/) print hash } + ' "$1" +} TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/${PKG_BINARY}-install.XXXXXX") trap 'rm -rf "$TMPDIR"' EXIT echo "Downloading $BINARY $VERSION_PATH ($OS-$ARCH)..." -if ! curl -fsSL "$URL" -o "$TMPDIR/$BINARY"; then - echo "install.sh: download failed: $URL" >&2 - exit 1 -fi +fetch "$RELEASE_URL/$ASSET" "$TMPDIR/$BINARY" || die "download failed: $RELEASE_URL/$ASSET" -# Checksum verify if the sums file exists and references this asset -if curl -fsSL "$SUMS_URL" -o "$TMPDIR/checksums.sha256" 2>/dev/null; then - if grep -q "$ASSET" "$TMPDIR/checksums.sha256"; then - expected=$(grep "$ASSET" "$TMPDIR/checksums.sha256" | awk '{print $1}') - # Portable SHA-256: shasum (macOS, BSD, Linux with perl-base) or - # openssl as fallback. Both available on every system we ship to. - if command -v shasum >/dev/null 2>&1; then - actual=$(shasum -a 256 "$TMPDIR/$BINARY" | awk '{print $1}') - elif command -v openssl >/dev/null 2>&1; then - actual=$(openssl dgst -sha256 "$TMPDIR/$BINARY" | awk '{print $NF}') - else - actual="" - fi - if [ -n "$actual" ] && [ "$expected" != "$actual" ]; then - echo "install.sh: checksum mismatch for $ASSET" >&2 - echo " expected: $expected" >&2 - echo " actual: $actual" >&2 - exit 1 - fi - if [ -n "$actual" ]; then - echo "Checksum verified." - fi +# Every release publishes .sha256, and checksums.sha256 is the older combined +# file. The first one that names this asset decides, so a mismatch never falls through. +expected="" +for sums in "$ASSET.sha256" checksums.sha256; do + if fetch "$RELEASE_URL/$sums" "$TMPDIR/$sums"; then + expected=$(sum_for "$TMPDIR/$sums" "$ASSET") fi + if [ -n "$expected" ]; then + break + fi +done +if [ -z "$expected" ]; then + die "no SHA-256 for $ASSET in $RELEASE_URL/$ASSET.sha256 or $RELEASE_URL/checksums.sha256, refusing to install an unverified binary" +fi + +actual=$(sha256_of "$TMPDIR/$BINARY") +[ -n "$actual" ] || die "could not compute the SHA-256 of $ASSET with $HASHER" +if [ "$expected" != "$actual" ]; then + echo "install.sh: checksum mismatch for $ASSET, refusing to install" >&2 + echo " expected: $expected" >&2 + echo " actual: $actual" >&2 + exit 1 fi +echo "Checksum verified: sha256 $actual" chmod +x "$TMPDIR/$BINARY" diff --git a/tests/install_sh.rs b/tests/install_sh.rs new file mode 100644 index 0000000..dbcfd38 --- /dev/null +++ b/tests/install_sh.rs @@ -0,0 +1,361 @@ +// Project: macbash +// File: tests/install_sh.rs +// Purpose: End-to-end tests for the checksum gate in the rendered install.sh +// Language: Rust +// +// License: Apache-2.0 +// Copyright: (c) 2026 HYPERI PTY LIMITED + +// 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}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use assert_cmd::Command; +use assert_cmd::assert::Assert; +use predicates::prelude::*; +use tempfile::TempDir; + +const VERSION: &str = "v9.9.9"; + +/// 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", + "macbash-linux-arm64", + "macbash-darwin-amd64", + "macbash-darwin-arm64", +]; + +/// Everything install.sh runs on the install path apart from a SHA-256 tool. +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| { + release.file(asset, BINARY) + }) + } + + 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| { + let body = format!("{hash} {asset}\n{OTHER_SHA256} {asset}.tar.gz\n"); + release.file(&format!("{asset}.sha256"), body) + }) + } +} + +/// 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 = + Path::new(env!("CARGO_MANIFEST_DIR")).join("packaging/templates/install.sh"); + let template = + std::fs::read_to_string(&template_path).expect("reading the install.sh 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_DEFAULT_DIR}", bin) + .replace("${PKG_USER_DIR}", bin); + assert!( + !rendered.contains("${PKG_"), + "install.sh uses a placeholder these tests do not render" + ); + let script = dir.path().join("install.sh"); + std::fs::write(&script, rendered).expect("writing the rendered install.sh"); + script +} + +fn install_dir(dir: &TempDir) -> PathBuf { + dir.path().join("bin") +} + +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"); + std::fs::create_dir(&tools).expect("creating the tools dir"); + for tool in BASE_TOOLS.iter().chain(hashers) { + let real = which(tool).unwrap_or_else(|| panic!("{tool} must be on PATH")); + std::os::unix::fs::symlink(real, tools.join(tool)).expect("linking a tool"); + } + tools.to_str().expect("tempdir paths are utf-8").to_string() +} + +/// Run the installer against `release`. `hashers` narrows PATH to those SHA-256 +/// tools plus the base tools, and `None` keeps the caller's PATH. +fn install(release: Release, hashers: Option<&[&str]>) -> (TempDir, Assert) { + 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 TMPDIR"); + let path = match hashers { + Some(hashers) => restricted_path(&dir, hashers), + None => std::env::var("PATH").expect("PATH is set"), + }; + let script = render(&dir, &serve(release)); + // A cleared environment keeps the caller's proxy settings and ~/.curlrc out of the run. + let run = Command::new("/bin/sh") + .arg(&script) + .arg("--user") + .arg("--dir") + .arg(install_dir(&dir)) + .args(["--version", VERSION]) + .env_clear() + .env("PATH", path) + .env("HOME", &home) + .env("TMPDIR", &tmp) + .timeout(Duration::from_secs(60)) + .assert(); + (dir, run) +} + +fn assert_not_installed(dir: &TempDir) { + assert!( + !installed(dir).exists(), + "install.sh must not install a binary it could not verify" + ); +} + +#[test] +fn installs_when_the_per_asset_checksum_matches() { + let (dir, run) = install(Release::with_binaries().per_asset_sums(BINARY_SHA256), None); + run.code(0) + .stdout(predicate::str::contains(format!( + "Checksum verified: sha256 {BINARY_SHA256}" + ))) + .stdout(predicate::str::contains( + "Installed: macbash 9.9.9 (test fixture)", + )); + let mode = std::fs::metadata(installed(&dir)) + .expect("the binary is installed") + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0, "the installed binary must be executable"); +} + +#[test] +fn installs_from_the_combined_sums_file_when_no_per_asset_file_exists() { + let combined: String = ASSETS + .iter() + .map(|asset| format!("{BINARY_SHA256} {asset}\n")) + .collect(); + let (dir, run) = install( + Release::with_binaries().file("checksums.sha256", combined), + None, + ); + run.code(0).stdout(predicate::str::contains(format!( + "Checksum verified: sha256 {BINARY_SHA256}" + ))); + assert!(installed(&dir).exists()); +} + +#[test] +fn refuses_when_no_sums_file_is_published() { + let (dir, run) = install(Release::with_binaries(), None); + run.code(1) + .stderr(predicate::str::contains( + "install.sh: no SHA-256 for macbash-", + )) + .stderr(predicate::str::contains( + "refusing to install an unverified binary", + )); + assert_not_installed(&dir); +} + +#[test] +fn refuses_when_the_sums_files_do_not_name_this_asset() { + // The binary's real hash sits against the archive name, so matching the asset + // name as a substring of a line would wrongly accept it. + let release = ASSETS + .iter() + .fold(Release::with_binaries(), |release, asset| { + release.file( + &format!("{asset}.sha256"), + format!("{BINARY_SHA256} {asset}.tar.gz\n"), + ) + }) + .file( + "checksums.sha256", + format!("{BINARY_SHA256} macbash-windows-amd64.exe\n"), + ); + let (dir, run) = install(release, None); + run.code(1).stderr(predicate::str::contains( + "install.sh: no SHA-256 for macbash-", + )); + assert_not_installed(&dir); +} + +#[test] +fn refuses_a_checksum_mismatch_even_when_the_combined_file_would_match() { + let combined: String = ASSETS + .iter() + .map(|asset| format!("{BINARY_SHA256} {asset}\n")) + .collect(); + let release = Release::with_binaries() + .per_asset_sums(OTHER_SHA256) + .file("checksums.sha256", combined); + let (dir, run) = install(release, None); + run.code(1) + .stderr(predicate::str::contains( + "install.sh: checksum mismatch for macbash-", + )) + .stderr(predicate::str::contains(format!( + "expected: {OTHER_SHA256}" + ))) + .stderr(predicate::str::contains(format!( + "actual: {BINARY_SHA256}" + ))); + assert_not_installed(&dir); +} + +#[test] +fn refuses_an_asset_listed_twice() { + let release = ASSETS + .iter() + .fold(Release::with_binaries(), |release, asset| { + release.file( + &format!("{asset}.sha256"), + format!("{BINARY_SHA256} {asset}\n{OTHER_SHA256} {asset}\n"), + ) + }); + let (dir, run) = install(release, None); + run.code(1).stderr(predicate::str::contains( + "install.sh: no SHA-256 for macbash-", + )); + assert_not_installed(&dir); +} + +#[test] +fn refuses_when_the_binary_download_fails() { + let (dir, run) = install(Release::default().per_asset_sums(BINARY_SHA256), None); + run.code(1) + .stderr(predicate::str::contains("install.sh: download failed:")); + assert_not_installed(&dir); +} + +#[test] +fn refuses_before_downloading_when_the_host_has_no_sha256_tool() { + let (dir, run) = install( + Release::with_binaries().per_asset_sums(BINARY_SHA256), + Some(&[]), + ); + run.code(1) + .stdout(predicate::str::contains("Downloading").not()) + .stderr(predicate::str::contains( + "install.sh: no SHA-256 tool found (need sha256sum, shasum or openssl)", + )); + assert_not_installed(&dir); +} + +#[test] +fn verifies_with_each_supported_sha256_tool() { + let available: Vec<&str> = ["sha256sum", "shasum", "openssl"] + .into_iter() + .filter(|hasher| which(hasher).is_some()) + .collect(); + assert!( + !available.is_empty(), + "this host has no SHA-256 tool, so install.sh cannot install anything" + ); + for hasher in available { + let (dir, run) = install( + Release::with_binaries().per_asset_sums(BINARY_SHA256), + Some(&[hasher]), + ); + run.code(0).stdout(predicate::str::contains(format!( + "Checksum verified: sha256 {BINARY_SHA256}" + ))); + assert!(installed(&dir).exists(), "{hasher} path did not install"); + } +}