From e5f2d23c9a0694f0ef555519cda24053b8aa1581 Mon Sep 17 00:00:00 2001 From: Derek Date: Wed, 14 Oct 2026 15:32:19 +1100 Subject: [PATCH] fix: refuse unverified installs and fork publishes install.sh installed whatever it downloaded when the sums file was missing, did not name the asset, or no hash tool was present. It now refuses all three, matches the exact asset name rather than a substring, and reads the per-asset .sha256 the release already publishes before the combined checksums.sha256. Cross-build and Package ran their publish jobs after any successful CI run, fork pull requests included. Both now need the upstream run on main, in this repository, from a push or a dispatch. Re-runs no longer replace published bytes. Each upload writes nothing when its versioned marker file is already on R2, and build.rs stamps SOURCE_DATE_EPOCH or the commit time instead of the clock, so one commit builds one binary. Fixes #10 --- .github/workflows/cross-build.yml | 54 +++-- .github/workflows/package.yml | 92 ++++++-- action.yml | 3 +- build.rs | 42 ++-- docs/release.md | 12 + packaging/README.md | 13 +- packaging/templates/install.sh | 91 +++++--- tests/install_sh.rs | 361 ++++++++++++++++++++++++++++++ 8 files changed, 581 insertions(+), 87 deletions(-) create mode 100644 tests/install_sh.rs 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"); + } +}