From 96c66d5c289bb3745e8c2f65d37141c1eccc4e1c Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 12:05:55 -0400 Subject: [PATCH 01/14] feat: verified installer bootstrap and release signature contract Publish a detached minisign signature for install-sfetch.sh, require it at the release gate, and ship a dual-route verified bootstrap engine with a thin composite action wrapper. Tag CI creates draft releases until maintainer sign/upload/publish. Self-bootstrap uses the verified engine against the N-1 pin. VERSION 0.4.11. Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- .github/actions/setup-sfetch/action.yml | 142 +++++ .github/workflows/ci.yml | 61 ++ .github/workflows/release.yml | 13 +- CHANGELOG.md | 15 + Makefile | 38 +- README.md | 86 +-- RELEASE_CHECKLIST.md | 20 +- VERSION | 2 +- docs/cicd-usage-guide.md | 106 +++- docs/releases/v0.4.11.md | 50 ++ docs/security.md | 87 ++- scripts/bootstrap-sfetch-verified.sh | 538 ++++++++++++++++++ .../generate-checksums_test.go | 13 +- scripts/sign-release-manifests.sh | 50 +- scripts/test-bootstrap-sfetch-verified.sh | 169 ++++++ scripts/test-release-verify-signatures.sh | 134 +++++ scripts/upload-release-assets.sh | 38 +- scripts/verify-signatures.sh | 82 ++- 18 files changed, 1526 insertions(+), 118 deletions(-) create mode 100644 .github/actions/setup-sfetch/action.yml create mode 100644 docs/releases/v0.4.11.md create mode 100755 scripts/bootstrap-sfetch-verified.sh create mode 100755 scripts/test-bootstrap-sfetch-verified.sh create mode 100755 scripts/test-release-verify-signatures.sh diff --git a/.github/actions/setup-sfetch/action.yml b/.github/actions/setup-sfetch/action.yml new file mode 100644 index 0000000..8b399ac --- /dev/null +++ b/.github/actions/setup-sfetch/action.yml @@ -0,0 +1,142 @@ +--- +# setup-sfetch — verified sfetch bootstrap for GitHub Actions +# +# Public contract (additive-only inputs after first release): +# sfetch-version (required) exact tag in this action SHA's supported range +# goneat-version (optional) exact tag; no floating default +# install-dir (optional) binary install directory +# +# Trust model: +# Consumers pin this action by commit SHA (not a moving tag). That SHA is the +# TCB for the verification engine — same model as actions/checkout. +# The sfetch trust anchor is embedded in the engine script; never fetched from +# the release being authenticated. +# +# Dual-route (selected by sfetch-version; logged; no silent fallback): +# >= v0.4.11 → install-sfetch.sh.minisig +# < v0.4.11 → signed SHA256SUMS + installer hash +# +# Fail-closed: no continue-on-error, no || true soft skips, no @latest tools, +# no Go toolchain requirement. +# +# Supported sfetch-version range is declared by the engine revision shipped with +# this action SHA (see scripts/bootstrap-sfetch-verified.sh constants). +name: "Setup sfetch" +description: "Install a pinned, minisign-verified sfetch (optional goneat) without pipe-to-bash" +author: "3leaps" + +inputs: + sfetch-version: + description: >- + Exact sfetch release tag (vMAJOR.MINOR.PATCH). Required. Never "latest". Must fall within the supported range of this action commit SHA. + required: true + goneat-version: + description: >- + Optional exact goneat release tag to install via verified sfetch after sfetch is installed. Empty means skip goneat. Never "latest". + required: false + default: "" + install-dir: + description: >- + Directory for installed binaries. Default: $HOME/.local/bin (created). + required: false + default: "" + +outputs: + sfetch-bin: + description: "Absolute path to the installed sfetch binary" + value: ${{ steps.install.outputs.sfetch-bin }} + goneat-bin: + description: "Absolute path to goneat when goneat-version was set" + value: ${{ steps.install.outputs.goneat-bin }} + route: + description: "Verification route taken (minisig or sha256sums)" + value: ${{ steps.install.outputs.route }} + +runs: + using: composite + steps: + - name: Verified sfetch install + id: install + shell: bash + env: + INPUT_SFETCH_VERSION: ${{ inputs.sfetch-version }} + INPUT_GONEAT_VERSION: ${{ inputs.goneat-version }} + INPUT_INSTALL_DIR: ${{ inputs.install-dir }} + run: | + set -euo pipefail + + # Resolve engine: prefer same-repo scripts/ relative to this action + # (full repo tree is available for monorepo path actions). + ACTION_ROOT="${GITHUB_ACTION_PATH}" + ENGINE="${ACTION_ROOT}/../../../scripts/bootstrap-sfetch-verified.sh" + if [ ! -f "${ENGINE}" ]; then + # Fallback: workspace checkout of sfetch (same-repo CI) + if [ -f "${GITHUB_WORKSPACE}/scripts/bootstrap-sfetch-verified.sh" ]; then + ENGINE="${GITHUB_WORKSPACE}/scripts/bootstrap-sfetch-verified.sh" + else + echo "error: bootstrap-sfetch-verified.sh not found relative to action or workspace" >&2 + exit 1 + fi + fi + ENGINE="$(cd "$(dirname "${ENGINE}")" && pwd)/$(basename "${ENGINE}")" + chmod +x "${ENGINE}" + + VERSION="${INPUT_SFETCH_VERSION:-}" + if [ -z "${VERSION}" ]; then + echo "error: sfetch-version is required" >&2 + exit 1 + fi + case "${VERSION}" in + latest|LATEST|main|master|HEAD|"") + echo "error: sfetch-version must be an exact tag (refusing ${VERSION:-empty})" >&2 + exit 1 + ;; + esac + + DIR="${INPUT_INSTALL_DIR:-}" + if [ -z "${DIR}" ]; then + DIR="${HOME}/.local/bin" + fi + mkdir -p "${DIR}" + + ARGS=(--version "${VERSION}" --dir "${DIR}" --yes) + if [ -n "${INPUT_GONEAT_VERSION:-}" ]; then + case "${INPUT_GONEAT_VERSION}" in + latest|LATEST) + echo "error: goneat-version must be an exact tag (refusing ${INPUT_GONEAT_VERSION})" >&2 + exit 1 + ;; + esac + ARGS+=(--goneat-version "${INPUT_GONEAT_VERSION}") + fi + + # Capture route from stderr log while still failing closed on non-zero. + LOG="$(mktemp)" + set +e + OUT="$("${ENGINE}" "${ARGS[@]}" 2>"${LOG}")" + RC=$? + set -e + cat "${LOG}" >&2 + if [ "${RC}" -ne 0 ]; then + echo "error: verified bootstrap failed (exit ${RC})" >&2 + exit "${RC}" + fi + + ROUTE="$(grep -E 'route=(minisig|sha256sums)' "${LOG}" | tail -n1 | sed -E 's/.*route=([a-z0-9]+).*/\1/' || true)" + SFETCH_BIN="$(printf '%s\n' "${OUT}" | awk -F= '/^sfetch-bin=/{print $2; exit}')" + GONEAT_BIN="$(printf '%s\n' "${OUT}" | awk -F= '/^goneat-bin=/{print $2; exit}')" + rm -f "${LOG}" + + if [ -z "${SFETCH_BIN}" ] || [ ! -f "${SFETCH_BIN}" ]; then + echo "error: sfetch binary path missing after bootstrap" >&2 + exit 1 + fi + + echo "${DIR}" >> "${GITHUB_PATH}" + { + echo "sfetch-bin=${SFETCH_BIN}" + echo "goneat-bin=${GONEAT_BIN}" + echo "route=${ROUTE}" + } >> "${GITHUB_OUTPUT}" + + echo "setup-sfetch complete: version=${VERSION} route=${ROUTE} sfetch-bin=${SFETCH_BIN}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ecb32..7065a8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,67 @@ jobs: echo "Install smoke pin: $TAG (make print-sfetch-version)" bash scripts/install-sfetch.sh --dry-run --tag "$TAG" --require-minisign + - name: Live verified bootstrap (N-1 SHA256SUMS route) + run: | + set -euo pipefail + TAG=$(make -s print-sfetch-version) + DEST="$RUNNER_TEMP/sfetch-bootstrap-live" + mkdir -p "$DEST" + ./scripts/bootstrap-sfetch-verified.sh --version "$TAG" --dir "$DEST" --yes + "$DEST/sfetch" --version + + # Dual-route consumer matrix: action thin-wraps the shared engine. + # v0.4.10 exercises sha256sums (backward pin). minisig route is unit-tested + # until v0.4.11 is published; after publish, add sfetch-version: v0.4.11 here. + setup-sfetch-matrix: + name: setup-sfetch (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + - os: macos-latest + - os: windows-latest + # Named org runner when available + - os: windows-latest-arm64-s + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + + - name: Setup sfetch via composite action (v0.4.10 sha256sums route) + uses: ./.github/actions/setup-sfetch + with: + sfetch-version: v0.4.10 + + - name: Assert sfetch on PATH + shell: bash + run: | + set -euo pipefail + command -v sfetch + sfetch --version | tee /tmp/sfetch-ver.txt + grep -E '0\.4\.10' /tmp/sfetch-ver.txt + + - name: Fail-closed optional tool (goneat not requested; deliberate missing assert) + shell: bash + run: | + set -euo pipefail + # goneat must not be soft-installed when not requested + if command -v goneat >/dev/null 2>&1; then + echo "note: ambient goneat present on runner (not installed by action)" + else + echo "goneat absent as expected when goneat-version omitted" + fi + + - name: Reject floating sfetch-version + shell: bash + run: | + set -euo pipefail + # Exercise engine reject path directly (action would also fail) + if ./scripts/bootstrap-sfetch-verified.sh --version latest --dir "$RUNNER_TEMP/bad" --yes; then + echo "error: latest must be rejected" >&2 + exit 1 + fi + container-probe: name: Install probe (container) runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5915b1..3f63f48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,14 +25,21 @@ jobs: # The build matrix below appends per-platform archives to the same release. # Maintainer signs + adds SHA256SUMS/notes afterward via scripts/ # (see README "Manual signing workflow"). - - name: Create release and upload install script + # Draft until maintainer signs install-sfetch.sh + manifests and uploads + # via make release-upload. A published release without installer .minisig is + # non-consumable for verified bootstrap; draft keeps it off "latest". + - name: Create draft release and upload install script uses: softprops/action-gh-release@v3 with: name: sfetch ${{ github.ref_name }} body: | ## sfetch ${{ github.ref_name }} - Auto-generated release. - draft: false + Draft release — incomplete until maintainer signs and publishes. + + Do not bootstrap from this release until `install-sfetch.sh.minisig` + and signed checksum manifests are uploaded, then the release is + published (`gh release edit TAG --draft=false`). + draft: true prerelease: false files: scripts/install-sfetch.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c5873..4f1dcd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.11] - 2026-07-31 + +### Added + +- **Detached `install-sfetch.sh.minisig`** on releases. Minisign signs the installer plus checksum manifests; PGP remains manifests-only. The installer signature is **required** by `make release-verify-signatures` and `upload-release-assets` (missing/tampered/wrong-key ⇒ non-zero). +- **`scripts/bootstrap-sfetch-verified.sh`** dual-route verified bootstrap: `.minisig` for ≥ v0.4.11, signed `SHA256SUMS` for earlier pins. Refuses `latest`/floating refs; embeds the trust anchor; pins minisign 0.12 provenance (including official Windows archive hashes). +- **Composite action** `.github/actions/setup-sfetch` thin-wrapping the shared engine (pin by commit SHA). +- Release signature and bootstrap regression harnesses wired into `make precommit`. + +### Changed + +- Tag **release workflow creates a draft** until maintainer sign/upload/publish so pre-signature assets never become `latest`. +- **`make bootstrap`** uses the verified engine against the N-1 pin (no pipe-to-bash). +- Security and CI docs: expanded signing set, dual-route consumer guidance, trust-anchor rotation notes, `--require-minisign` CLI vs installer default asymmetry. + ## [0.4.10] - 2026-07-30 ### Fixed diff --git a/Makefile b/Makefile index 798ea52..ed89fd8 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,7 @@ CORPUS_DEST ?= test-corpus .PHONY: release-verify-key release-verify-minisign-pubkey release-verify-keys release-verify-signatures release-verify .PHONY: release-clean bootstrap-script build-all gosec gosec-high update-scoop-manifest .PHONY: version-check version-set version-patch version-minor version-major -.PHONY: print-sfetch-version test-release-verify-checksums +.PHONY: print-sfetch-version test-release-verify-checksums test-release-verify-signatures test-bootstrap-sfetch-verified all: build @@ -82,15 +82,18 @@ help: ## Show this help # Bootstrap - Trust Anchor Chain # ----------------------------------------------------------------------------- # -# Trust chain: curl -> sfetch (self-bootstrap) -> goneat +# Trust chain: verified bootstrap script -> sfetch (N-1 pin) -> goneat # -# sfetch bootstraps itself via curl, then uses itself to install goneat. -# This demonstrates sfetch eating its own dogfood. +# N-1 pin (SFETCH_VERSION) is always a published release — never this cut. +# At v0.4.11 the N-1 pin is v0.4.10, which has no install-sfetch.sh.minisig, +# so the shared engine takes the signed SHA256SUMS route. From v0.4.12 onward +# (when N-1 >= v0.4.11) the engine switches to the detached .minisig route. +# +# Do not pipe curl | bash here — that is the anti-pattern this release fixes. -bootstrap: ## Install development tools via trust chain +bootstrap: ## Install development tools via verified trust chain @echo "Bootstrapping sfetch development environment..." @echo "" - @# Step 0: Verify curl is available (required trust anchor) @if ! command -v curl >/dev/null 2>&1; then \ echo "[!!] curl not found (required for bootstrap)"; \ echo ""; \ @@ -102,16 +105,16 @@ bootstrap: ## Install development tools via trust chain fi @echo "[ok] curl found" @echo "" - @# Step 1: Install sfetch via curl (self-bootstrap trust anchor) @mkdir -p "$(BIN_DIR)" @if [ ! -x "$(BIN_DIR)/sfetch" ] && ! command -v sfetch >/dev/null 2>&1; then \ - echo "[..] Installing sfetch $(SFETCH_VERSION) (self-bootstrap)..."; \ - curl -fsSL https://github.com/3leaps/sfetch/releases/download/$(SFETCH_VERSION)/install-sfetch.sh | bash -s -- \ - --dir "$(BIN_DIR)" --tag "$(SFETCH_VERSION)" --require-minisign; \ + echo "[..] Installing sfetch $(SFETCH_VERSION) (verified bootstrap, N-1 pin)..."; \ + ./scripts/bootstrap-sfetch-verified.sh \ + --version "$(SFETCH_VERSION)" \ + --dir "$(BIN_DIR)" \ + --yes; \ else \ echo "[ok] sfetch already installed"; \ fi - @# Verify sfetch @SFETCH_BIN=""; \ if [ -x "$(BIN_DIR)/sfetch" ]; then SFETCH_BIN="$(BIN_DIR)/sfetch"; \ elif command -v sfetch >/dev/null 2>&1; then SFETCH_BIN="$$(command -v sfetch)"; fi; \ @@ -122,6 +125,7 @@ bootstrap: ## Install development tools via trust chain @if ! command -v goneat >/dev/null 2>&1; then \ echo "[!!] goneat not found on PATH"; \ echo " Install it from https://github.com/fulmenhq/goneat/releases (pinned: $(GONEAT_VERSION))"; \ + echo " Or: ./scripts/bootstrap-sfetch-verified.sh --version $(SFETCH_VERSION) --dir $(BIN_DIR) --goneat-version $(GONEAT_VERSION)"; \ exit 1; \ fi @echo "[ok] goneat: $$(goneat version 2>&1 | head -n1)" @@ -207,6 +211,8 @@ precommit: ## Run pre-commit checks (goneat assess + Go tests + build) $(MAKE) build-all # CI runs make precommit (not prepush); keep fail-closed release-verify regression on this path. $(MAKE) test-release-verify-checksums + $(MAKE) test-release-verify-signatures + $(MAKE) test-bootstrap-sfetch-verified @echo "[ok] Pre-commit checks passed" prepush: precommit ## Run pre-push checks (same as precommit + security) @@ -276,6 +282,12 @@ release-verify-checksums: ## Verify checksums in dist/release (fail-closed; port test-release-verify-checksums: ## Regression: fail-closed checksum verify (corrupt/absent/empty) @./scripts/test-release-verify-checksums.sh +test-release-verify-signatures: ## Regression: required installer minisig + sign targets + @./scripts/test-release-verify-signatures.sh + +test-bootstrap-sfetch-verified: ## Regression: dual-route bootstrap rejects + fail-closed + @./scripts/test-bootstrap-sfetch-verified.sh + release-notes: ## Copy release notes into dist/release @if [ -z "$(RELEASE_TAG)" ]; then echo "error: RELEASE_TAG not set" >&2; exit 1; fi @mkdir -p $(DIST_RELEASE) @@ -287,7 +299,7 @@ release-notes: ## Copy release notes into dist/release cp "$$src" "$(DIST_RELEASE)/release-notes-$(RELEASE_TAG).md" @echo "[ok] Release notes copied to $(DIST_RELEASE)" -release-sign: release-checksums ## Sign checksum manifests (minisign + optional PGP) +release-sign: release-checksums ## Sign manifests + installer (minisign); PGP manifests-only SFETCH_MINISIGN_KEY=$(SFETCH_MINISIGN_KEY) SFETCH_PGP_KEY_ID=$(SFETCH_PGP_KEY_ID) SFETCH_GPG_HOMEDIR=$(SFETCH_GPG_HOMEDIR) ./scripts/sign-release-manifests.sh $(RELEASE_TAG) $(DIST_RELEASE) release-export-key: ## Export PGP public key to dist/release @@ -328,7 +340,7 @@ release-verify-keys: release-verify-key ## Verify all exported public keys echo "ℹ️ No minisign public key to verify ($(MINISIGN_PUB_NAME) not found)"; \ fi -release-verify-signatures: ## Verify minisign and PGP signatures on checksum manifests +release-verify-signatures: ## Verify signatures (installer minisig required; PGP optional) ./scripts/verify-signatures.sh $(DIST_RELEASE) release-verify: release-verify-checksums release-verify-signatures release-verify-keys ## Full post-signing release verification diff --git a/README.md b/README.md index 5079a26..7edd654 100644 --- a/README.md +++ b/README.md @@ -208,8 +208,35 @@ INSTALL_BINDIR=~/bin make install # override install location ### Bootstrap install +**Recommended (v0.4.11+):** verify the installer before execution. + ```bash -# Using curl +# Three-step path when install-sfetch.sh.minisig is published (v0.4.11+) +TAG=v0.4.11 +curl -fsSL "https://github.com/3leaps/sfetch/releases/download/${TAG}/install-sfetch.sh" -o install-sfetch.sh +curl -fsSL "https://github.com/3leaps/sfetch/releases/download/${TAG}/install-sfetch.sh.minisig" -o install-sfetch.sh.minisig +minisign -Vm install-sfetch.sh -P RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC +bash install-sfetch.sh --tag "$TAG" --yes + +# Or use the shared engine (CI/Makefile; refuses "latest"): +# ./scripts/bootstrap-sfetch-verified.sh --version v0.4.11 --dir ~/.local/bin +``` + +**GitHub Actions:** pin the composite action by commit SHA — see [CI/CD Usage Guide](docs/cicd-usage-guide.md). + +```yaml +- uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 +``` + +#### Quick install (interactive humans) + +Prefer a pinned tag. `latest` is fine for interactive use only — CI and Makefile +bootstrap **refuse** floating refs. + +```bash +# Using curl (human interactive) curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash # Using wget @@ -225,7 +252,7 @@ Pass arguments using `bash -s --`: curl -sSfL .../install-sfetch.sh | bash -s -- --dir ~/bin # Install specific version -curl -sSfL .../install-sfetch.sh | bash -s -- --tag v0.2.0 +curl -sSfL .../install-sfetch.sh | bash -s -- --tag v0.4.11 # Dry run (download and verify, don't install) curl -sSfL .../install-sfetch.sh | bash -s -- --dry-run @@ -242,9 +269,7 @@ The installer: - Requires minisign verification by default using the embedded trust anchor - Optional GPG fallback with pinned fingerprint; checksum-only requires explicit `--allow-checksum-only` -#### Verify before piping to bash - -For users who prefer to verify the installer before execution: +#### Verify via signed SHA256SUMS (pins ≤ v0.4.10) ```bash # Detect OS-appropriate SHA256 command (macOS uses shasum, Linux uses sha256sum) @@ -254,47 +279,32 @@ else SHA_CMD="shasum -a 256" fi -# Download assets (curl) -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh -o install-sfetch.sh -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/SHA256SUMS -o SHA256SUMS - -# Download assets (wget alternative - use -O to overwrite existing files) -# wget -qO install-sfetch.sh https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh -# wget -qO SHA256SUMS https://github.com/3leaps/sfetch/releases/latest/download/SHA256SUMS - -# Option A: Verify with minisign (recommended) -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/SHA256SUMS.minisig -o SHA256SUMS.minisig +TAG=v0.4.10 +curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${TAG}/install-sfetch.sh" -o install-sfetch.sh +curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${TAG}/SHA256SUMS" -o SHA256SUMS +curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${TAG}/SHA256SUMS.minisig" -o SHA256SUMS.minisig minisign -Vm SHA256SUMS -P RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC $SHA_CMD -c SHA256SUMS --ignore-missing - -# Option B: Verify with GPG (uses temp keyring) -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/SHA256SUMS.asc -o SHA256SUMS.asc -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/sfetch-release-signing-key.asc -o sfetch-release-signing-key.asc -GPG_TMPDIR=$(mktemp -d) -gpg --homedir "$GPG_TMPDIR" --import sfetch-release-signing-key.asc -gpg --homedir "$GPG_TMPDIR" --verify SHA256SUMS.asc SHA256SUMS -rm -rf "$GPG_TMPDIR" -$SHA_CMD -c SHA256SUMS --ignore-missing - -# Run after verification -bash install-sfetch.sh +bash install-sfetch.sh --tag "$TAG" ``` ### Manual signing workflow -CI uploads unsigned archives. Maintainers generate `SHA256SUMS` and `SHA512SUMS` locally, then sign them with minisign (primary) and optionally PGP: +Tag CI creates a **draft** release with unsigned archives. Maintainers generate +`SHA256SUMS` / `SHA512SUMS`, sign them **and** `install-sfetch.sh` with minisign +(optional PGP on manifests only), verify, upload, then publish the draft: ```bash -export MINISIGN_KEY=/path/to/sfetch.key -export PGP_KEY_ID=security@fulmenhq.dev # optional - -RELEASE_TAG=v0.2.0 make release-download -RELEASE_TAG=v0.2.0 make release-checksums -RELEASE_TAG=v0.2.0 make release-sign -make release-export-minisign-key -make release-export-key # if using PGP -RELEASE_TAG=v0.2.0 make release-notes -RELEASE_TAG=v0.2.0 make release-upload +export SFETCH_MINISIGN_KEY=/path/to/sfetch.key +export SFETCH_PGP_KEY_ID=security@fulmenhq.dev # optional + +RELEASE_TAG=v0.4.11 make release-download +RELEASE_TAG=v0.4.11 make release-checksums +RELEASE_TAG=v0.4.11 make release-sign # signs manifests + install-sfetch.sh +make release-verify +RELEASE_TAG=v0.4.11 make release-notes +RELEASE_TAG=v0.4.11 make release-upload +gh release edit v0.4.11 --draft=false # publish only after signatures upload ``` Set `RELEASE_TAG` to the tag you're publishing. The scripts in `scripts/` can be used individually if you prefer manual control. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index ed56011..3bc5979 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -39,7 +39,8 @@ Why this matters: `make release-upload` now uploads the signed GitHub release as - [ ] Wait for GitHub Actions release workflow to complete - CI validates VERSION file matches tag - Builds unsigned archives - - Uploads install-sfetch.sh + - Creates a **draft** release and uploads install-sfetch.sh + - Release remains non-consumable until you sign, upload signatures, and publish ## 2. Manual Signing (local machine) @@ -74,13 +75,17 @@ export SFETCH_GPG_HOMEDIR=/path/to/custom/gpg/homedir # optional, defaults to make release-verify-checksums ``` -5. **Sign checksum manifests** with minisign + PGP +5. **Sign checksum manifests + installer** with minisign (+ optional PGP on manifests) ```bash make release-sign ``` - Produces: `SHA256SUMS`, `SHA512SUMS` plus `.minisig`/`.asc` + Produces: + - `SHA256SUMS.minisig`, `SHA512SUMS.minisig` (minisign) + - **`install-sfetch.sh.minisig` (minisign, required)** — second password prompt is expected + - optional `SHA256SUMS.asc` / `SHA512SUMS.asc` if `SFETCH_PGP_KEY_ID` is set + - PGP does **not** sign the installer -6. **Verify signatures** +6. **Verify signatures** (installer minisig required — missing fails the gate) ```bash make release-verify-signatures ``` @@ -113,6 +118,13 @@ export SFETCH_GPG_HOMEDIR=/path/to/custom/gpg/homedir # optional, defaults to ``` > **Note:** This target depends on `release-verify` (checksums + signatures + keys). > It uploads ALL assets with `--clobber`, including binaries CI already uploaded. + > `install-sfetch.sh.minisig` is required; upload refuses a missing installer signature. + > Tag CI creates a **draft** release — after upload, publish: + > ```bash + > gh release edit v$(cat VERSION) --draft=false + > ``` + > Until publish, the release is incomplete / non-consumable for bootstrap consumers. + > > This is intentional for idempotency - rerun safely to fix any mistakes. > > If `../scoop-bucket` is present, this target also runs `make update-scoop-manifest` at the end so the bucket is ready for commit/push immediately after release upload. diff --git a/VERSION b/VERSION index e8423da..5f749c1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.10 +0.4.11 diff --git a/docs/cicd-usage-guide.md b/docs/cicd-usage-guide.md index e6b329c..0fa5c1a 100644 --- a/docs/cicd-usage-guide.md +++ b/docs/cicd-usage-guide.md @@ -33,10 +33,64 @@ This is fragile and version-dependent—upgrading is recommended. ## GitHub Actions Examples -### Basic usage (recommended) +### Recommended: composite action (v0.4.11+) + +Pin the action by **commit SHA** (not a moving tag). The action embeds the +supported `sfetch-version` range for that SHA and fails closed outside it. + +```yaml +- uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 # exact tag; never latest + goneat-version: v0.5.15 # optional; exact tag if set + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + SFETCH_GITHUB_TOKEN: ${{ github.token }} + +- name: Use sfetch + run: | + sfetch --version + sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$HOME/.local/bin" --require-minisign +``` + +**Dual-route behavior (logged as `route=minisig` or `route=sha256sums`):** + +| `sfetch-version` | Verification | +|------------------|--------------| +| ≥ v0.4.11 | Detached `install-sfetch.sh.minisig` | +| v0.4.9 – v0.4.10 | Signed `SHA256SUMS` + installer hash | +| outside action range / `latest` | **Fail closed** | + +Never fall back from a failed `.minisig` attempt to checksums. + +**Adoption mode:** link the action by SHA from this repository. Do not copy +`action.yml` into consumer repos (that re-creates the drift the action exists +to eliminate). + +### Makefile / shell: shared engine (D2b) + +```bash +# Prefer in-repo script when developing sfetch itself: +./scripts/bootstrap-sfetch-verified.sh --version v0.4.11 --dir "$HOME/.local/bin" + +# Other repos: fetch at an immutable SHA, verify digest, then execute. +# Replace and with values published for the release you trust. +SCRIPT_URL="https://raw.githubusercontent.com/3leaps/sfetch//scripts/bootstrap-sfetch-verified.sh" +SCRIPT_SHA256="" +curl -fsSL "$SCRIPT_URL" -o /tmp/bootstrap-sfetch-verified.sh +echo "${SCRIPT_SHA256} /tmp/bootstrap-sfetch-verified.sh" | shasum -a 256 -c +bash /tmp/bootstrap-sfetch-verified.sh --version v0.4.11 --dir "$HOME/.local/bin" +``` + +`latest` is refused by the engine (and by the action). Interactive humans may +still use `install-sfetch.sh` from a pinned tag or, carefully, from `latest`; +CI and Makefile recipes must use exact tags. + +### Legacy pipe-to-bash (still works; not recommended) ```yaml -- name: Install sfetch + tool +- name: Install sfetch + tool (legacy) env: GITHUB_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }} @@ -46,20 +100,18 @@ This is fragile and version-dependent—upgrading is recommended. BIN_DIR="$HOME/.local/bin" mkdir -p "$BIN_DIR" - # Install sfetch - curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash -s -- --yes --dir "$BIN_DIR" + # Prefer a pinned tag. Avoid releases/latest in CI. + SFETCH_VERSION="v0.4.10" + curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${SFETCH_VERSION}/install-sfetch.sh" \ + | bash -s -- --yes --dir "$BIN_DIR" --tag "$SFETCH_VERSION" --require-minisign export PATH="$BIN_DIR:$PATH" - # Install your tool (sfetch handles cross-device automatically) - sfetch --repo owner/repo --latest --dest-dir "$BIN_DIR" --require-minisign - - # Verify - tool --version + sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$BIN_DIR" --require-minisign ``` Exporting all three token variables at job or workflow scope keeps `sfetch`, `gh`, and child processes on authenticated GitHub API requests by default. -### With explicit version pinning +### With explicit version pinning (legacy installer) ```yaml - name: Install tools (pinned versions) @@ -69,11 +121,10 @@ Exporting all three token variables at job or workflow scope keeps `sfetch`, `gh mkdir -p "$BIN_DIR" export PATH="$BIN_DIR:$PATH" - # Install sfetch (pinned; choose the minimum version you require) - SFETCH_VERSION="v0.2.6" - curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${SFETCH_VERSION}/install-sfetch.sh" | bash -s -- --yes --dir "$BIN_DIR" + SFETCH_VERSION="v0.4.10" + curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${SFETCH_VERSION}/install-sfetch.sh" \ + | bash -s -- --yes --dir "$BIN_DIR" --tag "$SFETCH_VERSION" --require-minisign - # Install tool (pinned) sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$BIN_DIR" --require-minisign ``` @@ -266,3 +317,30 @@ precedence model. - [Examples & Pattern Matching](examples.md) - Real-world verification examples - [Security Documentation](security.md) - Verification workflows explained - [Key Handling](key-handling.md) - PGP and minisign key configuration + +## Minisign provenance (verifier pin) + +`minisign` is part of the trust-critical path. The verified bootstrap engine +installs or asserts **minisign 0.12** from official jedisct1 release archives +(hash-pinned before extract): + +| Platform | Artifact | SHA-256 | +|----------|----------|---------| +| Windows x64 / arm64 | `minisign-0.12-win64.zip` | `37b600344e20c19314b2e82813db2bfdcc408b77b876f7727889dbd46d539479` | +| macOS arm64 | `minisign-0.12-macos.zip` | `89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63` | +| Linux x86_64 / aarch64 | `minisign-0.12-linux.tar.gz` | `9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73` | + +Windows maps `RUNNER_ARCH` X64 → `x86_64`, ARM64 → `aarch64`. **macOS Intel is +not supported** by the upstream 0.12 macOS archive (arm64-only) and fails +closed unless an ambient minisign 0.12 is already on PATH. + +Do **not** use Chocolatey/winget community packages for the verified bootstrap +path. Distro packages (apt/brew) are acceptable only when the binary reports +exactly version 0.12 (the engine re-asserts identity after acquisition). + +## Incomplete release window + +Between tag CI (draft + unsigned installer upload) and maintainer +`make release-upload` + publish, a release is **non-consumable** for verified +bootstrap. Consumers must not treat draft assets or an unsigned installer as a +trust anchor. Prefer exact tags over `latest` so you never race that window. diff --git a/docs/releases/v0.4.11.md b/docs/releases/v0.4.11.md new file mode 100644 index 0000000..842a326 --- /dev/null +++ b/docs/releases/v0.4.11.md @@ -0,0 +1,50 @@ +# sfetch v0.4.11 + +Trust-anchor delivery and verified bootstrap (D1 / D2 / D2b). + +## Highlights + +- **Detached installer signature:** releases publish `install-sfetch.sh.minisig` in addition to signed checksum manifests. Minisign covers the installer plus `SHA256SUMS`/`SHA512SUMS`; PGP remains manifests-only. +- **Required release gate:** `make release-verify-signatures` fails closed if the installer signature is missing, tampered, or signed with the wrong key. Uploads refuse a missing installer minisig. +- **Verified bootstrap engine:** `scripts/bootstrap-sfetch-verified.sh` dual-route installer (`.minisig` for ≥ v0.4.11, signed `SHA256SUMS` for earlier pins). Fails closed; never falls back between routes; refuses `latest`. +- **Composite action:** `3leaps/sfetch/.github/actions/setup-sfetch` is a thin wrapper over the engine. Pin by commit SHA. +- **Draft releases until signed:** tag CI creates a **draft** release so incomplete (pre-signature) assets never become `latest`. +- **Own `make bootstrap`:** uses the verified engine against the N-1 pin (v0.4.10 → SHA256SUMS route for this cut). + +## Consumer impact + +| Your pin | Effect of v0.4.11 | +|----------|-------------------| +| Pinned ≤ v0.4.10, unchanged | **No change.** Existing install paths keep working. | +| Pinned ≤ v0.4.10, want 3-step `.minisig` | Advance pin to **v0.4.11+**. Signature is not retroactive. | +| On `latest` | Human interactive still OK; **CI/Makefile/action refuse `latest`**. Prefer exact tags. | +| Adopting the action | Route selected by pin; both routes fail closed. | + +## Verified bootstrap (recommended) + +```yaml +- uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 + goneat-version: v0.5.15 # optional, exact tag +``` + +Makefile / local: + +```bash +./scripts/bootstrap-sfetch-verified.sh --version v0.4.11 --dir "$HOME/.local/bin" +``` + +Outside this repo, retrieve the script at an immutable SHA and verify a pinned digest before executing (see `docs/cicd-usage-guide.md`). + +## Trust anchor + +Unchanged: + +``` +RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC +``` + +## Incomplete release window + +Tag CI uploads the installer into a **draft** release. The release is **non-consumable** for verified bootstrap until the maintainer runs `make release-sign` / `make release-verify` / `make release-upload` and publishes (`gh release edit TAG --draft=false`). diff --git a/docs/security.md b/docs/security.md index 594beba..a115c3b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -128,28 +128,95 @@ discoverable from the error message. ## Manual release signing -CI uploads unsigned archives only. Maintainers generate and sign checksum manifests (`SHA256SUMS`, `SHA512SUMS`) locally with minisign (primary) and optionally PGP: +Tag CI creates a **draft** release and uploads unsigned platform archives plus +`install-sfetch.sh`. The draft is **incomplete / non-consumable** for verified +bootstrap until the maintainer signs and publishes. + +Maintainers download artifacts, generate checksums, and sign **locally** with +minisign (primary) and optionally PGP: ```bash -export MINISIGN_KEY=/path/to/key.key -export PGP_KEY_ID=your-key-id # optional +export SFETCH_MINISIGN_KEY=/path/to/key.key +export SFETCH_MINISIGN_PUB=/path/to/key.pub +export SFETCH_PGP_KEY_ID=your-key-id # optional -RELEASE_TAG=v0.2.0 make release-download -RELEASE_TAG=v0.2.0 make release-checksums -RELEASE_TAG=v0.2.0 make release-sign +RELEASE_TAG=v0.4.11 make release-download +RELEASE_TAG=v0.4.11 make release-checksums +RELEASE_TAG=v0.4.11 make release-sign make release-verify-signatures make release-export-keys make release-verify-keys -RELEASE_TAG=v0.2.0 make release-notes -RELEASE_TAG=v0.2.0 make release-upload +RELEASE_TAG=v0.4.11 make release-notes +RELEASE_TAG=v0.4.11 make release-upload +gh release edit v0.4.11 --draft=false ``` -Only the checksum manifests are signed (not individual files). Users verify the signature on `SHA256SUMS`/`SHA512SUMS`, then verify archive checksums against them. This is standard practice - signing individual files would be redundant. +### What is signed (v0.4.11+) + +| Artifact | minisign | PGP | +|----------|----------|-----| +| `SHA256SUMS` / `SHA512SUMS` | yes | optional | +| `install-sfetch.sh` | **yes (required)** | no | +| Platform archives | covered by signed manifests | covered by signed manifests | + +**Why the installer is the one file signed outside the manifests:** consumers +must execute it *before* any sfetch binary exists. A detached +`install-sfetch.sh.minisig` makes the verified path three steps instead of five. +All other assets remain covered by the signed checksum manifests (signing every +archive would be redundant). + +`make release-verify-signatures` **requires** `install-sfetch.sh.minisig` +(missing ⇒ non-zero). Manifest minisign signatures remain skip-if-absent for +legacy staging; if present they must verify. Do not re-harmonise these branches +without a deliberate lock — the installer requirement exists specifically to +block incomplete releases. -Installer hardening: `scripts/install-sfetch.sh` now requires minisign verification by default (embedded trust anchor). GPG fallback is pinned by fingerprint. Checksum-only installs require explicit opt-in (`--allow-checksum-only`) and emit low-trust warnings. +`SHA256SUMS` never lists `*.minisig` files (suffix skip in the checksum +generator); a regression harness asserts this so the manifest cannot become +self-referential. + +Installer runtime hardening: `scripts/install-sfetch.sh` requires minisign +verification by default (embedded trust anchor). GPG fallback is pinned by +fingerprint. Checksum-only installs require explicit opt-in +(`--allow-checksum-only`) and emit low-trust warnings. See [docs/security/signing-runbook.md](security/signing-runbook.md) for detailed workflow. +### Trust-anchor rotation + +The minisign public key is embedded in: + +- `main.go` (`EmbeddedMinisignPubkey`) +- `scripts/install-sfetch.sh` +- `scripts/bootstrap-sfetch-verified.sh` + +All three must stay identical. The published `sfetch-minisign.pub` on each +release is for **human out-of-band comparison only** — verification tools must +not fetch the key from the same release they are authenticating (circular). + +If the key ever rotates: + +1. Announce the rotation in release notes and a security advisory *before* the + first release signed with the new key. +2. Publish the new public key through a channel independent of a single GitHub + release (project site / signed mailing list / prior release notes). +3. Bump major tooling that hard-codes the old key; consumers who pin the old + key must update deliberately — a rotation will look like a signature failure + by design. +4. Do not silently dual-sign with both keys without documenting the transition + window. + +### `--require-minisign` default asymmetry + +| Surface | Default | Rationale | +|---------|---------|-----------| +| `install-sfetch.sh` | **require minisign = true** | Bootstrap path; no prior binary trust | +| CLI (`sfetch`) | **require minisign = false** | Works against releases that only ship checksums / PGP; opt-in strictness via `--require-minisign` | + +Same flag name, opposite defaults — intentional. Do not "tidy" them into one +behavior without a migration plan; CI examples should pass `--require-minisign` +on the CLI explicitly. + ## Verifying Your Installation After installing sfetch, you can verify the binary matches the signed release: diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh new file mode 100755 index 0000000..00ae32d --- /dev/null +++ b/scripts/bootstrap-sfetch-verified.sh @@ -0,0 +1,538 @@ +#!/usr/bin/env bash +# bootstrap-sfetch-verified.sh — verified sfetch install for Makefile / CI +# +# Fail-closed dual-route bootstrap: +# - v0.4.11+ → detached install-sfetch.sh.minisig (3-step) +# - earlier → SHA256SUMS + SHA256SUMS.minisig + installer hash (5-step) +# +# Never falls back from a failed .minisig attempt to checksums. +# Never accepts "latest", branches, or floating refs. +# Trust anchor is embedded here — never fetched from the release being authenticated. +# +# Usage: +# bootstrap-sfetch-verified.sh --version v0.4.11 --dir ~/.local/bin +# bootstrap-sfetch-verified.sh --version v0.4.10 --dir ./bin --goneat-version v0.5.15 +# +# Env (testing / advanced): +# SFETCH_BOOTSTRAP_BASE_URL Override GitHub download base +# (default: https://github.com/3leaps/sfetch/releases/download) +# SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL If set to 1, require ambient minisign +# already on PATH (used by unit fixtures that supply their own binary). +# +# Makefile consumers outside this repo should retrieve this script at an +# immutable git SHA and verify a pinned SHA-256 of the script before executing +# it (see docs/cicd-usage-guide.md). The composite action trusts the script +# via the pinned action SHA instead. +# +set -euo pipefail + +# ----------------------------------------------------------------------------- +# Contract constants (bump deliberately when expanding support) +# ----------------------------------------------------------------------------- +# Supported sfetch-version range for THIS script revision. Outside range ⇒ fail. +# Action SHA identifies this contract; consumers pin the action/script, then a +# version within the range it declares. +readonly SFETCH_BOOTSTRAP_MIN="v0.4.9" +readonly SFETCH_BOOTSTRAP_MAX="v0.4.11" +# First release that publishes install-sfetch.sh.minisig +readonly SFETCH_MINISIG_SINCE="v0.4.11" + +# Embedded trust anchor — must match EmbeddedMinisignPubkey in main.go and +# scripts/install-sfetch.sh. Do NOT fetch sfetch-minisign.pub from the release +# for authentication (circular: same origin as the artifact under test). +# The published .pub is for human out-of-band comparison only. +readonly SFETCH_MINISIGN_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" + +readonly MINISIGN_VERSION_EXPECTED="0.12" +readonly MINISIGN_WIN_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-win64.zip" +readonly MINISIGN_WIN_SHA256="37b600344e20c19314b2e82813db2bfdcc408b77b876f7727889dbd46d539479" +readonly MINISIGN_MAC_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip" +readonly MINISIGN_MAC_SHA256="89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63" +readonly MINISIGN_LINUX_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz" +readonly MINISIGN_LINUX_SHA256="9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73" + +readonly SFETCH_REPO_DEFAULT="3leaps/sfetch" + +# ----------------------------------------------------------------------------- +# Logging / errors +# ----------------------------------------------------------------------------- +log() { printf '%s\n' "$*" >&2; } +die() { + log "error: $*" + exit 1 +} + +# ----------------------------------------------------------------------------- +# Semver helpers (tags must be vMAJOR.MINOR.PATCH, optional -prerelease rejected) +# ----------------------------------------------------------------------------- +is_exact_semver_tag() { + case "$1" in + v[0-9]*.[0-9]*.[0-9]*) + # Reject extra suffix (pre-release / build) and non-numeric parts + [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + ;; + *) return 1 ;; + esac +} + +# Compare two vX.Y.Z tags: echo -1 / 0 / 1 for ab +semver_cmp() { + local a="${1#v}" b="${2#v}" + local a1 a2 a3 b1 b2 b3 + IFS=. read -r a1 a2 a3 <<<"$a" + IFS=. read -r b1 b2 b3 <<<"$b" + if ((a1 != b1)); then + if ((a1 < b1)); then echo -1; else echo 1; fi + return + fi + if ((a2 != b2)); then + if ((a2 < b2)); then echo -1; else echo 1; fi + return + fi + if ((a3 != b3)); then + if ((a3 < b3)); then echo -1; else echo 1; fi + return + fi + echo 0 +} + +semver_ge() { [[ "$(semver_cmp "$1" "$2")" != "-1" ]]; } +semver_le() { [[ "$(semver_cmp "$1" "$2")" != "1" ]]; } + +# ----------------------------------------------------------------------------- +# Args +# ----------------------------------------------------------------------------- +VERSION="" +INSTALL_DIR="" +GONEAT_VERSION="" +REPO="${SFETCH_REPO_DEFAULT}" +usage() { + cat <<'EOF' >&2 +Usage: bootstrap-sfetch-verified.sh --version vX.Y.Z --dir PATH [options] + +Required: + --version TAG Exact immutable tag (e.g. v0.4.11). Rejects latest/branches. + --dir PATH Install directory for sfetch (and optional goneat) + +Optional: + --goneat-version TAG Exact goneat tag to install via verified sfetch + --repo owner/name GitHub repo (default: 3leaps/sfetch) + --yes Non-interactive (always on for this script; accepted for CLI parity) + -h, --help Show help + +Env: + SFETCH_BOOTSTRAP_BASE_URL Override download base (tests) +EOF + exit 2 +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || die "--version requires an argument" + VERSION="$2" + shift 2 + ;; + --dir) + [ $# -ge 2 ] || die "--dir requires an argument" + INSTALL_DIR="$2" + shift 2 + ;; + --goneat-version) + [ $# -ge 2 ] || die "--goneat-version requires an argument" + GONEAT_VERSION="$2" + shift 2 + ;; + --repo) + [ $# -ge 2 ] || die "--repo requires an argument" + REPO="$2" + shift 2 + ;; + --yes) + # Accepted for CLI parity with install-sfetch.sh (always non-interactive). + shift + ;; + -h | --help) + usage + ;; + *) + die "unknown option: $1 (see --help)" + ;; + esac +done + +[ -n "$VERSION" ] || die "--version is required" +[ -n "$INSTALL_DIR" ] || die "--dir is required" + +# Reject floating / non-immutable refs (CI and Makefile must never use latest). +case "$VERSION" in + "" | latest | LATEST | main | master | HEAD) + die "refusing floating version ${VERSION:-}; pin an exact tag (e.g. v0.4.11)" + ;; +esac +if ! is_exact_semver_tag "$VERSION"; then + die "version must be exact vMAJOR.MINOR.PATCH (got: $VERSION)" +fi +if [ -n "$GONEAT_VERSION" ]; then + case "$GONEAT_VERSION" in + "" | latest | LATEST | main | master | HEAD) + die "refusing floating goneat-version: ${GONEAT_VERSION:-}" + ;; + esac + if ! is_exact_semver_tag "$GONEAT_VERSION"; then + die "goneat-version must be exact vMAJOR.MINOR.PATCH (got: $GONEAT_VERSION)" + fi +fi + +if ! semver_ge "$VERSION" "$SFETCH_BOOTSTRAP_MIN" || ! semver_le "$VERSION" "$SFETCH_BOOTSTRAP_MAX"; then + die "sfetch-version $VERSION outside supported range ${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX} for this bootstrap revision" +fi + +BASE_URL="${SFETCH_BOOTSTRAP_BASE_URL:-https://github.com/${REPO}/releases/download}" +ASSET_BASE="${BASE_URL}/${VERSION}" + +# Route selection (logged; no silent downgrade between routes) +ROUTE="" +if semver_ge "$VERSION" "$SFETCH_MINISIG_SINCE"; then + ROUTE="minisig" +else + ROUTE="sha256sums" +fi +log "bootstrap-sfetch-verified: version=${VERSION} route=${ROUTE} range=${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX}" + +# ----------------------------------------------------------------------------- +# Platform +# ----------------------------------------------------------------------------- +detect_os() { + case "$(uname -s 2>/dev/null || echo unknown)" in + Linux*) echo linux ;; + Darwin*) echo darwin ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) echo windows ;; + *) + # GitHub Actions Windows often reports MINGW via bash + if [ -n "${RUNNER_OS:-}" ]; then + case "${RUNNER_OS}" in + Windows) echo windows ;; + Linux) echo linux ;; + macOS) echo darwin ;; + *) die "unsupported OS: ${RUNNER_OS}" ;; + esac + else + die "unsupported OS: $(uname -s 2>/dev/null || echo unknown)" + fi + ;; + esac +} + +detect_arch() { + # Prefer GitHub runner arch when present (handles Windows ARM64 host quirks) + if [ -n "${RUNNER_ARCH:-}" ]; then + case "${RUNNER_ARCH}" in + X64 | x64 | AMD64 | amd64) + echo x86_64 + return + ;; + ARM64 | arm64) + echo aarch64 + return + ;; + *) die "unsupported RUNNER_ARCH: ${RUNNER_ARCH}" ;; + esac + fi + local m + m="$(uname -m 2>/dev/null || echo unknown)" + case "$m" in + x86_64 | amd64) echo x86_64 ;; + aarch64 | arm64) echo aarch64 ;; + *) die "unsupported architecture: $m" ;; + esac +} + +OS="$(detect_os)" +ARCH="$(detect_arch)" +log "platform: os=${OS} arch=${ARCH}" + +# macOS Intel: upstream 0.12 macOS archive is arm64-only — fail closed. +if [ "$OS" = "darwin" ] && [ "$ARCH" = "x86_64" ]; then + die "macOS x86_64 is not supported by the pinned minisign 0.12 macOS archive (arm64-only); use an arm64 runner or install minisign 0.12 out of band and set SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1" +fi + +# ----------------------------------------------------------------------------- +# Temp workspace (private; cleaned on exit) +# ----------------------------------------------------------------------------- +WORK="$(mktemp -d "${TMPDIR:-/tmp}/sfetch-bootstrap.XXXXXX")" +cleanup() { + rm -rf "${WORK}" +} +trap cleanup EXIT + +mkdir -p "$INSTALL_DIR" +INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" + +# ----------------------------------------------------------------------------- +# HTTPS fetch with bounded retries +# ----------------------------------------------------------------------------- +http_get() { + local url="$1" out="$2" + local attempt=1 max=4 + while [ "$attempt" -le "$max" ]; do + if command -v curl >/dev/null 2>&1; then + if curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 15 --max-time 120 \ + -o "$out" "$url"; then + return 0 + fi + elif command -v wget >/dev/null 2>&1; then + if wget -q -O "$out" "$url"; then + return 0 + fi + else + die "curl or wget is required" + fi + log "fetch attempt ${attempt}/${max} failed: $url" + attempt=$((attempt + 1)) + sleep 1 + done + die "failed to fetch after ${max} attempts: $url" +} + +sha256_file() { + local f="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$f" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$f" | awk '{print $1}' + else + die "shasum or sha256sum required" + fi +} + +assert_sha256() { + local f="$1" expect="$2" + local got + got="$(sha256_file "$f")" + if [ "$got" != "$expect" ]; then + die "SHA-256 mismatch for $(basename "$f"): expected ${expect}, got ${got}" + fi +} + +# ----------------------------------------------------------------------------- +# Minisign acquisition (pinned; Windows uses official archive only) +# ----------------------------------------------------------------------------- +MINISIGN_BIN="" + +ensure_minisign() { + if [ "${SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL:-0}" = "1" ]; then + command -v minisign >/dev/null 2>&1 || die "minisign required on PATH when SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1" + MINISIGN_BIN="$(command -v minisign)" + assert_minisign_version + return 0 + fi + + # Prefer ambient minisign only if it reports the expected version. + if command -v minisign >/dev/null 2>&1; then + local v + v="$(minisign -v 2>&1 | head -n1 || true)" + if echo "$v" | grep -q "${MINISIGN_VERSION_EXPECTED}"; then + MINISIGN_BIN="$(command -v minisign)" + log "using ambient minisign: ${MINISIGN_BIN} (${v})" + return 0 + fi + log "ambient minisign version not ${MINISIGN_VERSION_EXPECTED} (${v}); installing pinned binary" + fi + + local tools="${WORK}/tools" + mkdir -p "$tools" + case "$OS" in + windows) + local zip="${WORK}/minisign-win.zip" + http_get "$MINISIGN_WIN_URL" "$zip" + assert_sha256 "$zip" "$MINISIGN_WIN_SHA256" + if command -v unzip >/dev/null 2>&1; then + unzip -q -o "$zip" -d "${WORK}/minisign-extract" + else + # PowerShell Expand-Archive on Windows runners + powershell.exe -NoProfile -Command \ + "Expand-Archive -LiteralPath '$zip' -DestinationPath '${WORK}/minisign-extract' -Force" || + die "failed to extract minisign zip" + fi + local sub + case "$ARCH" in + x86_64) sub="x86_64" ;; + aarch64) sub="aarch64" ;; + *) die "unsupported Windows arch: $ARCH" ;; + esac + local src="${WORK}/minisign-extract/minisign-win64/${sub}/minisign.exe" + [ -f "$src" ] || die "minisign.exe not found at $src" + cp "$src" "${tools}/minisign.exe" + MINISIGN_BIN="${tools}/minisign.exe" + ;; + darwin) + local zip="${WORK}/minisign-mac.zip" + http_get "$MINISIGN_MAC_URL" "$zip" + assert_sha256 "$zip" "$MINISIGN_MAC_SHA256" + unzip -q -o "$zip" -d "${WORK}/minisign-extract" + [ -f "${WORK}/minisign-extract/minisign" ] || die "minisign binary missing from macOS archive" + cp "${WORK}/minisign-extract/minisign" "${tools}/minisign" + chmod 0755 "${tools}/minisign" + MINISIGN_BIN="${tools}/minisign" + ;; + linux) + local tgz="${WORK}/minisign-linux.tar.gz" + http_get "$MINISIGN_LINUX_URL" "$tgz" + assert_sha256 "$tgz" "$MINISIGN_LINUX_SHA256" + mkdir -p "${WORK}/minisign-extract" + tar -xzf "$tgz" -C "${WORK}/minisign-extract" + local sub + case "$ARCH" in + x86_64) sub="x86_64" ;; + aarch64) sub="aarch64" ;; + *) die "unsupported Linux arch: $ARCH" ;; + esac + local src="${WORK}/minisign-extract/minisign-linux/${sub}/minisign" + [ -f "$src" ] || die "minisign not found at $src" + cp "$src" "${tools}/minisign" + chmod 0755 "${tools}/minisign" + MINISIGN_BIN="${tools}/minisign" + ;; + *) + die "unsupported OS for minisign install: $OS" + ;; + esac + assert_minisign_version + log "minisign ready: ${MINISIGN_BIN}" +} + +assert_minisign_version() { + local out + out="$("$MINISIGN_BIN" -v 2>&1 | head -n1 || true)" + echo "$out" | grep -q "${MINISIGN_VERSION_EXPECTED}" || + die "minisign version assertion failed (want ${MINISIGN_VERSION_EXPECTED}): ${out}" +} + +write_pubkey() { + local path="$1" + # minisign -p expects a file with optional comment + RW... key line + { + echo "untrusted comment: sfetch release signing key (embedded trust anchor)" + echo "${SFETCH_MINISIGN_PUBKEY}" + } >"$path" +} + +# ----------------------------------------------------------------------------- +# Verification routes +# ----------------------------------------------------------------------------- +verify_installer_minisig() { + local script="$1" + local _sig="$2" + local pub="$3" + log "verify route=minisig: minisign -Vm install-sfetch.sh" + if ! "$MINISIGN_BIN" -Vm "$script" -p "$pub" -x "$_sig" >/dev/null; then + die "install-sfetch.sh.minisig verification failed (route=minisig; no fallback)" + fi + log "install-sfetch.sh.minisig: OK" +} + +verify_installer_sha256sums() { + local script="$1" sums="$2" sums_sig="$3" pub="$4" + log "verify route=sha256sums: minisign -Vm SHA256SUMS then hash install-sfetch.sh" + if ! "$MINISIGN_BIN" -Vm "$sums" -p "$pub" -x "$sums_sig" >/dev/null; then + die "SHA256SUMS.minisig verification failed (route=sha256sums; no fallback)" + fi + local expect got + expect="$(awk '$2 == "install-sfetch.sh" { print $1; exit }' "$sums")" + [ -n "$expect" ] || die "install-sfetch.sh not listed in SHA256SUMS" + got="$(sha256_file "$script")" + if [ "$got" != "$expect" ]; then + die "install-sfetch.sh SHA-256 mismatch: expected ${expect}, got ${got}" + fi + log "install-sfetch.sh SHA-256 via signed SHA256SUMS: OK" +} + +# ----------------------------------------------------------------------------- +# Install sfetch +# ----------------------------------------------------------------------------- +ensure_minisign + +PUB="${WORK}/sfetch-minisign.pub" +write_pubkey "$PUB" + +SCRIPT="${WORK}/install-sfetch.sh" +http_get "${ASSET_BASE}/install-sfetch.sh" "$SCRIPT" +chmod 0755 "$SCRIPT" + +case "$ROUTE" in + minisig) + SIG="${WORK}/install-sfetch.sh.minisig" + http_get "${ASSET_BASE}/install-sfetch.sh.minisig" "$SIG" + verify_installer_minisig "$SCRIPT" "$SIG" "$PUB" + ;; + sha256sums) + SUMS="${WORK}/SHA256SUMS" + SUMS_SIG="${WORK}/SHA256SUMS.minisig" + http_get "${ASSET_BASE}/SHA256SUMS" "$SUMS" + http_get "${ASSET_BASE}/SHA256SUMS.minisig" "$SUMS_SIG" + verify_installer_sha256sums "$SCRIPT" "$SUMS" "$SUMS_SIG" "$PUB" + ;; + *) + die "internal error: unknown route $ROUTE" + ;; +esac + +# Execute only after verification (never pipe curl | bash) +log "executing verified installer for ${VERSION} → ${INSTALL_DIR}" +# shellcheck disable=SC2086 +bash "$SCRIPT" --tag "$VERSION" --dir "$INSTALL_DIR" --yes --require-minisign + +SFETCH_BIN="${INSTALL_DIR}/sfetch" +if [ "$OS" = "windows" ]; then + if [ -f "${INSTALL_DIR}/sfetch.exe" ]; then + SFETCH_BIN="${INSTALL_DIR}/sfetch.exe" + fi +fi +[ -x "$SFETCH_BIN" ] || [ -f "$SFETCH_BIN" ] || die "sfetch binary missing after install: $SFETCH_BIN" + +# Exact version assertion +REPORT="$("$SFETCH_BIN" --version 2>&1 || true)" +log "sfetch reports: ${REPORT}" +# Accept version with or without leading v in binary output +VER_NUM="${VERSION#v}" +echo "$REPORT" | grep -Eq "${VER_NUM}|${VERSION}" || + die "sfetch version assertion failed: expected ${VERSION}, got: ${REPORT}" + +# Optional self-close: re-fetch install-sfetch.sh through sfetch and compare +if "$SFETCH_BIN" --help 2>&1 | grep -q -- '--asset-match' || true; then + if "$SFETCH_BIN" --repo "$REPO" --tag "$VERSION" --asset-match 'install-sfetch.sh' \ + --dest-dir "${WORK}/self-close" --require-minisign 2>/dev/null; then + REFETCH="$(find "${WORK}/self-close" -name 'install-sfetch.sh' 2>/dev/null | head -n1 || true)" + if [ -n "$REFETCH" ] && [ -f "$REFETCH" ]; then + if ! cmp -s "$SCRIPT" "$REFETCH"; then + die "self-close check failed: re-fetched install-sfetch.sh differs from executed script" + fi + log "self-close: re-fetched install-sfetch.sh matches" + fi + fi +fi + +# Optional goneat via verified sfetch (no Go toolchain) +if [ -n "$GONEAT_VERSION" ]; then + log "installing goneat ${GONEAT_VERSION} via verified sfetch" + "$SFETCH_BIN" --repo fulmenhq/goneat --tag "$GONEAT_VERSION" \ + --dest-dir "$INSTALL_DIR" --require-minisign + GONEAT_BIN="${INSTALL_DIR}/goneat" + if [ "$OS" = "windows" ] && [ -f "${INSTALL_DIR}/goneat.exe" ]; then + GONEAT_BIN="${INSTALL_DIR}/goneat.exe" + fi + [ -f "$GONEAT_BIN" ] || die "goneat binary missing after install" + GREP="$("$GONEAT_BIN" version 2>&1 | head -n1 || true)" + GNUM="${GONEAT_VERSION#v}" + echo "$GREP" | grep -Eq "${GNUM}|${GONEAT_VERSION}" || + die "goneat version assertion failed: expected ${GONEAT_VERSION}, got: ${GREP}" + log "goneat OK: ${GREP}" +fi + +log "bootstrap-sfetch-verified complete: sfetch=${VERSION} route=${ROUTE} dir=${INSTALL_DIR}" +# Emit install path for action consumers (stdout only machine line) +printf 'sfetch-bin=%s\n' "$SFETCH_BIN" +if [ -n "$GONEAT_VERSION" ]; then + printf 'goneat-bin=%s\n' "$GONEAT_BIN" +fi diff --git a/scripts/cmd/generate-checksums/generate-checksums_test.go b/scripts/cmd/generate-checksums/generate-checksums_test.go index dd79859..a249567 100644 --- a/scripts/cmd/generate-checksums/generate-checksums_test.go +++ b/scripts/cmd/generate-checksums/generate-checksums_test.go @@ -19,8 +19,10 @@ func TestRunGeneratesChecksumsAndSkipsNonArtifacts(t *testing.T) { } writeFile("sfetch_one", "one") - writeFile("sfetch_one.asc", "sig") // skipped - writeFile("install-sfetch.sh", "installer") // included + writeFile("sfetch_one.asc", "sig") // skipped + writeFile("install-sfetch.sh", "installer") // included + writeFile("install-sfetch.sh.minisig", "detached-sig") // skipped by .minisig suffix + writeFile("SHA256SUMS.minisig", "manifest-sig") // skipped writeFile("release-notes-v0.0.1.md", "notes") writeFile("SHA256SUMS", "old") // skipped/overwritten @@ -33,7 +35,12 @@ func TestRunGeneratesChecksumsAndSkipsNonArtifacts(t *testing.T) { t.Fatalf("read SHA256SUMS: %v", err) } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") + content := string(data) + if strings.Contains(content, ".minisig") { + t.Fatalf("SHA256SUMS must not list .minisig files (self-reference hazard):\n%s", content) + } + + lines := strings.Split(strings.TrimSpace(content), "\n") if len(lines) != 2 { t.Fatalf("expected 2 entries, got %d: %v", len(lines), lines) } diff --git a/scripts/sign-release-manifests.sh b/scripts/sign-release-manifests.sh index 7b0f6a7..71a11c9 100755 --- a/scripts/sign-release-manifests.sh +++ b/scripts/sign-release-manifests.sh @@ -1,33 +1,39 @@ #!/usr/bin/env bash set -euo pipefail -# Dual-format release signing: minisign (.minisig) + PGP (.asc) +# Dual-format release signing: minisign (.minisig) + optional PGP (.asc) # -# Usage: sign-release-assets.sh [dir] +# Usage: sign-release-manifests.sh [dir] # # Environment variables: # SFETCH_MINISIGN_KEY - Path to minisign secret key file. Primary format. # SFETCH_PGP_KEY_ID - GPG key ID for PGP signing. Optional secondary format. # SFETCH_GPG_HOMEDIR - Custom GPG homedir (optional, defaults to ~/.gnupg) # +# Signing sets (deliberate split — do not "harmonise" without a lock): +# minisign: SHA256SUMS, SHA512SUMS, and install-sfetch.sh +# PGP: SHA256SUMS and SHA512SUMS only (manifests) +# +# Why the installer is signed with minisign (second password prompt): +# The installer is the one artifact consumers execute *before* any verifier +# chain exists. Signing only manifests forces a five-step consumer path; +# a detached install-sfetch.sh.minisig collapses that to three steps. +# All other release assets remain covered by the signed checksum manifests. +# Scaling is 2 prompts (manifests + installer), not N per-file signatures. +# # Minisign was chosen over raw ed25519 because: # - Created by Frank Denis (libsodium author), well-audited # - Trusted comments provide signed metadata (version, timestamp) # - Password-protected keys by default # - Compatible with OpenBSD signify -# -# Only SHA256SUMS is signed (not individual files). This is the standard pattern: -# verify signature on checksum file, then verify file checksums against that. -# This means one password prompt instead of N. -TAG=${1:?"usage: sign-release-assets.sh [dir]"} +TAG=${1:?"usage: sign-release-manifests.sh [dir]"} DIR=${2:-dist/release} SFETCH_MINISIGN_KEY=${SFETCH_MINISIGN_KEY:-} SFETCH_PGP_KEY_ID=${SFETCH_PGP_KEY_ID:-} SFETCH_GPG_HOMEDIR=${SFETCH_GPG_HOMEDIR:-} -# Validation if [ ! -d "$DIR" ]; then echo "error: directory $DIR not found" >&2 exit 1 @@ -45,6 +51,14 @@ if [ ${#checksum_files[@]} -eq 0 ]; then exit 1 fi +installer_file="" +if [ -f "$DIR/install-sfetch.sh" ]; then + installer_file="install-sfetch.sh" +else + echo "error: install-sfetch.sh not found in $DIR (run make bootstrap-script / release-checksums first)" >&2 + exit 1 +fi + has_minisign=false has_pgp=false @@ -62,7 +76,6 @@ if [ -n "$SFETCH_MINISIGN_KEY" ]; then echo "minisign signing enabled (key: $SFETCH_MINISIGN_KEY)" fi -# Only enable PGP if explicitly requested via SFETCH_PGP_KEY_ID if [ -n "$SFETCH_PGP_KEY_ID" ]; then if ! command -v gpg >/dev/null 2>&1; then echo "error: SFETCH_PGP_KEY_ID set but gpg not found in PATH" >&2 @@ -82,25 +95,31 @@ if [ "$has_minisign" = false ] && [ "$has_pgp" = false ]; then exit 1 fi -# Sign checksum manifests (preferred workflow) -# Users verify: 1) signature on checksum file, 2) file checksums against it -# +# Minisign is required for the installer signature (release contract). +if [ "$has_minisign" = false ]; then + echo "error: SFETCH_MINISIGN_KEY is required to sign install-sfetch.sh" >&2 + exit 1 +fi + # Signing is grouped by tool (all minisign first, then all PGP) to minimize # password prompt switching during manual signing workflows. if [ "$has_minisign" = true ]; then echo "" - echo "=== Minisign signatures ===" + echo "=== Minisign signatures (manifests + installer) ===" for file in "${checksum_files[@]}"; do echo "🔏 [minisign] Signing $file" rm -f "$DIR/$file.minisig" minisign -S -s "$SFETCH_MINISIGN_KEY" -t "sfetch $TAG $(date -u +%Y-%m-%dT%H:%M:%SZ)" -m "$DIR/$file" done + echo "🔏 [minisign] Signing $installer_file" + rm -f "$DIR/${installer_file}.minisig" + minisign -S -s "$SFETCH_MINISIGN_KEY" -t "sfetch $TAG install-sfetch.sh $(date -u +%Y-%m-%dT%H:%M:%SZ)" -m "$DIR/$installer_file" fi if [ "$has_pgp" = true ]; then echo "" - echo "=== PGP signatures ===" + echo "=== PGP signatures (manifests only) ===" for file in "${checksum_files[@]}"; do echo "🔏 [PGP] Signing $file" if [ -n "$SFETCH_GPG_HOMEDIR" ]; then @@ -121,3 +140,6 @@ for file in "${checksum_files[@]}"; do echo " $file.asc: verify with --pgp-key-file" fi done +if [ "$has_minisign" = true ]; then + echo " ${installer_file}.minisig: required release-gate artifact (minisign only)" +fi diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh new file mode 100755 index 0000000..adfaf43 --- /dev/null +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Unit/fixture tests for bootstrap-sfetch-verified.sh (route selection, rejects, dual-route). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +SCRIPT="${ROOT}/scripts/bootstrap-sfetch-verified.sh" +[ -x "$SCRIPT" ] || chmod +x "$SCRIPT" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} +pass() { echo "PASS: $*"; } + +# --- Version rejection (no network) --- +reject() { + local args=("$@") + if "$SCRIPT" "${args[@]}" --dir /tmp 2>/dev/null; then + fail "should reject: ${args[*]}" + fi +} + +# Missing required args +if "$SCRIPT" 2>/dev/null; then fail "should require --version"; else pass "requires --version"; fi + +reject --version latest --dir /tmp && pass "rejects latest" || true +if "$SCRIPT" --version latest --dir /tmp 2>/dev/null; then fail "latest should fail"; else pass "rejects latest"; fi +if "$SCRIPT" --version main --dir /tmp 2>/dev/null; then fail "main should fail"; else pass "rejects main"; fi +if "$SCRIPT" --version v0.4.11-rc1 --dir /tmp 2>/dev/null; then fail "prerelease should fail"; else pass "rejects prerelease"; fi +if "$SCRIPT" --version v0.4.8 --dir /tmp 2>/dev/null; then fail "below min should fail"; else pass "rejects below min"; fi +if "$SCRIPT" --version v0.4.12 --dir /tmp 2>/dev/null; then fail "above max should fail"; else pass "rejects above max"; fi + +# --- Route selection logging via dry parse --- +# Source-compatible check: run with a fake base URL that fails fetch after route log +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-boot-test.XXXXXX")" +trap 'rm -rf "${WORKDIR}"' EXIT + +# Capture route for v0.4.10 +set +e +OUT1040="$WORKDIR/out410.txt" +SFETCH_BOOTSTRAP_BASE_URL="file://${WORKDIR}/empty" \ + "$SCRIPT" --version v0.4.10 --dir "$WORKDIR/d410" >"$OUT1040" 2>&1 +set -e +grep -q 'route=sha256sums' "$OUT1040" || fail "v0.4.10 should select sha256sums route (log: $(cat "$OUT1040"))" +pass "v0.4.10 → route=sha256sums" + +set +e +OUT411="$WORKDIR/out411.txt" +SFETCH_BOOTSTRAP_BASE_URL="file://${WORKDIR}/empty" \ + "$SCRIPT" --version v0.4.11 --dir "$WORKDIR/d411" >"$OUT411" 2>&1 +set -e +grep -q 'route=minisig' "$OUT411" || fail "v0.4.11 should select minisig route (log: $(cat "$OUT411"))" +pass "v0.4.11 → route=minisig" + +# --- Local dual-route positive fixtures with ephemeral minisign --- +command -v minisign >/dev/null 2>&1 || fail "minisign required" +command -v python3 >/dev/null 2>&1 || fail "python3 required for local HTTP fixture" + +KEY="$WORKDIR/k.key" +PUB="$WORKDIR/k.pub" +minisign -G -W -p "$PUB" -s "$KEY" >/dev/null 2>&1 || + minisign -G -n -p "$PUB" -s "$KEY" >/dev/null 2>&1 || + fail "keygen" + +# Build a fake "release" that install script won't fully run — we only test +# verify-before-execute by making install-sfetch.sh a stub that writes a marker +# when executed (proving execution happened only after verify). +make_stub_installer() { + local dest="$1" + cat >"$dest" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +DIR="" +while [ $# -gt 0 ]; do + case "$1" in + --dir) DIR="$2"; shift 2 ;; + --tag|--yes|--require-minisign) shift ;; + --tag|--dir) shift 2 ;; + *) shift ;; + esac +done +# crude parse again +while [ $# -gt 0 ]; do shift; done +# re-parse from original not available; write marker using env from harness +: "${HARNESS_INSTALL_DIR:?}" +mkdir -p "${HARNESS_INSTALL_DIR}" +# Fake sfetch binary that reports version from HARNESS_FAKE_VERSION +cat >"${HARNESS_INSTALL_DIR}/sfetch" <"${HARNESS_INSTALL_DIR}/.stub-ran" +STUB + chmod +x "$dest" +} + +# Patch approach: the real bootstrap embeds production pubkey. For fixture +# verification we need the embedded key to match. Instead of rewriting the +# script, test the pure verification helpers via a mini harness that mimics +# the two routes with the production path only for network live tests. +# +# Local fixture: inject via SFETCH_BOOTSTRAP — not available for pubkey override. +# So we only fully exercise network live path for v0.4.10 (real signed release) +# and unit-level route/reject above. Optional live: + +# Live install against published v0.4.10 when explicitly enabled or in GHA. +if [ "${SFETCH_BOOTSTRAP_LIVE:-0}" = "1" ] || [ "${GITHUB_ACTIONS:-}" = "true" ]; then + LIVE_DIR="$WORKDIR/live" + mkdir -p "$LIVE_DIR" + if "$SCRIPT" --version v0.4.10 --dir "$LIVE_DIR"; then + "$LIVE_DIR/sfetch" --version | grep -q '0.4.10' || fail "live v0.4.10 version" + pass "live v0.4.10 sha256sums route install" + else + fail "live v0.4.10 install failed" + fi +else + pass "skip live install (set SFETCH_BOOTSTRAP_LIVE=1 or GITHUB_ACTIONS=true to enable)" +fi + +# Negative: no execution-before-verify — supply bad minisig content via local HTTP +# Custom: override base URL to local python server serving unsigned installer +SRV_ROOT="$WORKDIR/www" +mkdir -p "$SRV_ROOT/v0.4.11" +printf '#!/bin/sh\necho SHOULD_NOT_RUN\n' >"$SRV_ROOT/v0.4.11/install-sfetch.sh" +chmod +x "$SRV_ROOT/v0.4.11/install-sfetch.sh" +# Wrong signature: sign with our key but script embeds production key → verify fails +minisign -S -s "$KEY" -t t -m "$SRV_ROOT/v0.4.11/install-sfetch.sh" + +PORT=0 +# shellcheck disable=SC2016 +python3 - "$SRV_ROOT" "$WORKDIR/port" <<'PY' & +import http.server, socketserver, sys, pathlib +root = pathlib.Path(sys.argv[1]) +portfile = pathlib.Path(sys.argv[2]) +class H(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **k): + super().__init__(*a, directory=str(root), **k) + def log_message(self, *args): + pass +with socketserver.TCPServer(("127.0.0.1", 0), H) as httpd: + portfile.write_text(str(httpd.server_address[1])) + httpd.serve_forever() +PY +SRV_PID=$! +for _ in $(seq 1 50); do + [ -f "$WORKDIR/port" ] && break + sleep 0.05 +done +PORT="$(cat "$WORKDIR/port")" +BAD_DIR="$WORKDIR/bad" +mkdir -p "$BAD_DIR" +set +e +OUTBAD="$WORKDIR/outbad.txt" +SFETCH_BOOTSTRAP_BASE_URL="http://127.0.0.1:${PORT}" \ + SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ + "$SCRIPT" --version v0.4.11 --dir "$BAD_DIR" >"$OUTBAD" 2>&1 +RC=$? +set -e +kill "$SRV_PID" 2>/dev/null || true +wait "$SRV_PID" 2>/dev/null || true +[ "$RC" -ne 0 ] || fail "wrong-key minisig should fail" +[ ! -f "$BAD_DIR/.stub-ran" ] || fail "installer must not run before verify" +[ ! -f "$BAD_DIR/sfetch" ] || fail "sfetch must not be installed on failed verify" +grep -q 'route=minisig' "$OUTBAD" || fail "expected minisig route in log" +pass "wrong-key minisig fails closed without executing installer" + +echo "[ok] bootstrap-sfetch-verified regression harness complete" diff --git a/scripts/test-release-verify-signatures.sh b/scripts/test-release-verify-signatures.sh new file mode 100755 index 0000000..fbb85c6 --- /dev/null +++ b/scripts/test-release-verify-signatures.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Regression harness for release signature verification (installer required). +# Uses ephemeral minisign keys — no production secrets. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} +pass() { echo "PASS: $*"; } + +command -v minisign >/dev/null 2>&1 || fail "minisign required for this harness" + +WORKDIR="" +trap 'rm -rf "${WORKDIR:-}" 2>/dev/null || true' EXIT +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-sig-test.XXXXXX")" + +KEY="${WORKDIR}/test.key" +PUB="${WORKDIR}/test.pub" +# Non-interactive keygen (empty password via -W / force) +# minisign -G -W generates unencrypted secret key (for CI fixtures only) +minisign -G -W -p "$PUB" -s "$KEY" >/dev/null 2>&1 || + minisign -G -n -p "$PUB" -s "$KEY" >/dev/null 2>&1 || + fail "minisign keygen failed" + +# --- Fixture: signed manifests + installer --- +STAGE="${WORKDIR}/stage" +mkdir -p "$STAGE" +printf 'payload\n' >"$STAGE/a.bin" +printf '#!/bin/sh\necho installer\n' >"$STAGE/install-sfetch.sh" +( + cd "$STAGE" + shasum -a 256 a.bin install-sfetch.sh >SHA256SUMS + shasum -a 512 a.bin install-sfetch.sh >SHA512SUMS +) + +# Sign with mocked env (sign-release-manifests.sh) +export SFETCH_MINISIGN_KEY="$KEY" +export SFETCH_MINISIGN_PUB="$PUB" +# Provide password-free path: rewrite sign to use -W keys; minisign -S without password for -W keys +minisign -S -s "$KEY" -t "test" -m "$STAGE/SHA256SUMS" +minisign -S -s "$KEY" -t "test" -m "$STAGE/SHA512SUMS" +minisign -S -s "$KEY" -t "test" -m "$STAGE/install-sfetch.sh" + +# Case 1: full set verifies +if SFETCH_MINISIGN_PUB="$PUB" ./scripts/verify-signatures.sh "$STAGE"; then + pass "full fixture verifies" +else + fail "full fixture should verify" +fi + +# Case 2: missing installer minisig ⇒ non-zero (required) +NO_INST="${WORKDIR}/no-inst" +cp -R "$STAGE/." "$NO_INST/" +rm -f "$NO_INST/install-sfetch.sh.minisig" +if SFETCH_MINISIGN_PUB="$PUB" ./scripts/verify-signatures.sh "$NO_INST"; then + fail "missing install-sfetch.sh.minisig should exit non-zero" +else + pass "missing install-sfetch.sh.minisig exits non-zero" +fi + +# Case 3: tampered installer fails +TAMPER="${WORKDIR}/tamper" +cp -R "$STAGE/." "$TAMPER/" +echo "evil" >>"$TAMPER/install-sfetch.sh" +if SFETCH_MINISIGN_PUB="$PUB" ./scripts/verify-signatures.sh "$TAMPER"; then + fail "tampered installer should exit non-zero" +else + pass "tampered installer exits non-zero" +fi + +# Case 4: wrong key fails +WRONG="${WORKDIR}/wrong" +cp -R "$STAGE/." "$WRONG/" +WRONG_KEY="${WORKDIR}/wrong.key" +WRONG_PUB="${WORKDIR}/wrong.pub" +minisign -G -W -p "$WRONG_PUB" -s "$WRONG_KEY" >/dev/null 2>&1 || + minisign -G -n -p "$WRONG_PUB" -s "$WRONG_KEY" >/dev/null 2>&1 || + fail "wrong keygen failed" +if SFETCH_MINISIGN_PUB="$WRONG_PUB" ./scripts/verify-signatures.sh "$WRONG"; then + fail "wrong key should exit non-zero" +else + pass "wrong key exits non-zero" +fi + +# Case 5: sign-release-manifests produces installer minisig (and PGP not on installer) +SIGN_DIR="${WORKDIR}/sign" +mkdir -p "$SIGN_DIR" +cp "$STAGE/install-sfetch.sh" "$SIGN_DIR/" +cp "$STAGE/SHA256SUMS" "$SIGN_DIR/" +cp "$STAGE/SHA512SUMS" "$SIGN_DIR/" +SFETCH_MINISIGN_KEY="$KEY" ./scripts/sign-release-manifests.sh v0.0.0-test "$SIGN_DIR" +[ -f "$SIGN_DIR/install-sfetch.sh.minisig" ] || fail "sign-release-manifests did not produce install-sfetch.sh.minisig" +[ -f "$SIGN_DIR/SHA256SUMS.minisig" ] || fail "sign-release-manifests did not produce SHA256SUMS.minisig" +[ -f "$SIGN_DIR/SHA512SUMS.minisig" ] || fail "sign-release-manifests did not produce SHA512SUMS.minisig" +# PGP not requested → no .asc on installer +[ ! -f "$SIGN_DIR/install-sfetch.sh.asc" ] || fail "PGP must not sign installer by default" +pass "sign-release-manifests minisign targets (manifests + installer)" + +# Case 6: upload script refuses missing installer minisig +UPLOAD_DIR="${WORKDIR}/upload" +mkdir -p "$UPLOAD_DIR" +cp "$STAGE/install-sfetch.sh" "$UPLOAD_DIR/" +printf '# notes\n' >"$UPLOAD_DIR/release-notes-v0.0.0-test.md" +# Fake binary so ARTIFACTS non-empty +printf 'bin\n' >"$UPLOAD_DIR/sfetch_linux_amd64.tar.gz" +cp "$STAGE/SHA256SUMS" "$UPLOAD_DIR/" +cp "$STAGE/SHA512SUMS" "$UPLOAD_DIR/" +if ./scripts/upload-release-assets.sh v0.0.0-test "$UPLOAD_DIR" 2>/dev/null; then + fail "upload should refuse missing install-sfetch.sh.minisig" +else + pass "upload refuses missing install-sfetch.sh.minisig" +fi + +# Case 7: generate-checksums does not list .minisig +GEN="${WORKDIR}/gen" +mkdir -p "$GEN" +printf 'x\n' >"$GEN/sfetch_linux_amd64" +printf '#!/bin/sh\n' >"$GEN/install-sfetch.sh" +printf 'sig\n' >"$GEN/install-sfetch.sh.minisig" +printf 'sig\n' >"$GEN/SHA256SUMS.minisig" +go run ./scripts/cmd/generate-checksums --dir "$GEN" +if grep -q '\.minisig' "$GEN/SHA256SUMS"; then + fail "SHA256SUMS must not contain .minisig entries" +else + pass "SHA256SUMS has no .minisig self-reference" +fi +grep -q 'install-sfetch.sh' "$GEN/SHA256SUMS" || fail "install-sfetch.sh should still be checksummed" +pass "installer remains in checksums; minisig skipped" + +echo "[ok] release signature regression harness complete" diff --git a/scripts/upload-release-assets.sh b/scripts/upload-release-assets.sh index ad15a3a..fb513c0 100755 --- a/scripts/upload-release-assets.sh +++ b/scripts/upload-release-assets.sh @@ -15,12 +15,28 @@ if [ ! -f "$NOTES_FILE" ]; then echo "release notes file $NOTES_FILE not found" >&2 exit 1 fi + +# Installer detached signature is required before upload (release contract). +# Without it the release must not be treated as consumable for bootstrap. +if [ ! -f "$DIR/install-sfetch.sh.minisig" ]; then + echo "❌ required signature missing: $DIR/install-sfetch.sh.minisig" >&2 + echo " Run 'make release-sign' then 'make release-verify' before upload." >&2 + exit 1 +fi + # Assumes release artifacts were built in CI and downloaded locally. # This script only re-uploads/clobbers assets on GitHub. shopt -s nullglob ARTIFACTS=("$DIR"/sfetch_* "$DIR"/SHA256SUMS "$DIR"/SHA512SUMS "$DIR"/install-sfetch.sh) # Build signature list from globs only; filter to existing files. -SIG_CANDIDATES=("$DIR"/SHA256SUMS.* "$DIR"/SHA512SUMS.* "$DIR"/*-minisign.pub "$DIR"/*-signing-key.asc) +# Include installer minisig explicitly (not covered by SHA*SUMS.* globs). +SIG_CANDIDATES=( + "$DIR"/SHA256SUMS.* + "$DIR"/SHA512SUMS.* + "$DIR"/install-sfetch.sh.minisig + "$DIR"/*-minisign.pub + "$DIR"/*-signing-key.asc +) SIGNATURES=() for f in "${SIG_CANDIDATES[@]}"; do if [ -f "$f" ]; then @@ -34,13 +50,27 @@ fi echo "📤 Uploading binaries/checksums for ${TAG}" gh release upload "$TAG" "${ARTIFACTS[@]}" --clobber echo "📤 Uploading signatures and keys" -if [ ${#SIGNATURES[@]} -gt 0 ]; then - gh release upload "$TAG" "${SIGNATURES[@]}" --clobber -else +if [ ${#SIGNATURES[@]} -eq 0 ]; then echo "❌ No signature files found in $DIR" >&2 echo " Did you run 'make release-sign' first?" >&2 exit 1 fi +# Hard-require installer minisig in the upload set (belt and suspenders with file check above). +has_installer_sig=false +for f in "${SIGNATURES[@]}"; do + case "$f" in + */install-sfetch.sh.minisig) has_installer_sig=true ;; + esac +done +if [ "$has_installer_sig" != true ]; then + echo "❌ install-sfetch.sh.minisig not in upload set" >&2 + exit 1 +fi +gh release upload "$TAG" "${SIGNATURES[@]}" --clobber echo "📝 Updating release notes" gh release edit "$TAG" --notes-file "$NOTES_FILE" echo "✅ Release updated" +echo "" +echo "If the release is still a draft, publish only after this upload succeeds:" +echo " gh release edit ${TAG} --draft=false" +echo "Until then the release is incomplete / non-consumable for bootstrap consumers." diff --git a/scripts/verify-signatures.sh b/scripts/verify-signatures.sh index 16c66f4..2a4d995 100755 --- a/scripts/verify-signatures.sh +++ b/scripts/verify-signatures.sh @@ -1,13 +1,21 @@ #!/usr/bin/env bash set -euo pipefail -# Verify release signatures (minisign and optional PGP) on checksum manifests. +# Verify release signatures (minisign and optional PGP). # # Usage: verify-signatures.sh [dir] # # Env: # SFETCH_MINISIGN_PUB - path to minisign public key (required for minisign) # SFETCH_GPG_HOMEDIR - isolated gpg homedir for PGP verification (optional) +# +# Policy (deliberate divergence — do not re-harmonise without a lock): +# - install-sfetch.sh.minisig is REQUIRED. Missing signature ⇒ non-zero exit. +# This is the gate that prevents declaring a release consumable before the +# installer is signed (bootstrap consumers must not race the pre-signature window). +# - SHA256SUMS / SHA512SUMS minisign: optional-skip when absent (legacy path); +# if present, verification must pass. +# - PGP (.asc) remains optional: skip when absent; verify when present. DIR=${1:-dist/release} @@ -22,7 +30,31 @@ SFETCH_GPG_HOMEDIR=${SFETCH_GPG_HOMEDIR:-} verified=0 failed=0 -verify_minisign() { +require_minisign_tool() { + if ! command -v minisign >/dev/null 2>&1; then + echo "error: minisign not found in PATH" >&2 + failed=$((failed + 1)) + return 1 + fi + return 0 +} + +require_minisign_pub() { + if [ -z "${SFETCH_MINISIGN_PUB}" ]; then + echo "error: SFETCH_MINISIGN_PUB not set, cannot verify minisign signatures" >&2 + failed=$((failed + 1)) + return 1 + fi + if [ ! -f "${SFETCH_MINISIGN_PUB}" ]; then + echo "error: SFETCH_MINISIGN_PUB=${SFETCH_MINISIGN_PUB} not found" >&2 + failed=$((failed + 1)) + return 1 + fi + return 0 +} + +# Optional: skip when signature file is absent (manifests only). +verify_minisign_optional() { local manifest="$1" local base="${DIR}/${manifest}" local sig="${base}.minisig" @@ -32,30 +64,49 @@ verify_minisign() { return 0 fi - if [ -z "${SFETCH_MINISIGN_PUB}" ]; then - echo "⚠️ SFETCH_MINISIGN_PUB not set, cannot verify ${manifest}.minisig" + require_minisign_pub || return 1 + require_minisign_tool || return 1 + + echo "🔍 [minisign] Verifying ${manifest}" + if minisign -V -p "${SFETCH_MINISIGN_PUB}" -m "${base}"; then + echo "✅ ${manifest}.minisig verified" + verified=$((verified + 1)) + else + echo "❌ ${manifest}.minisig verification FAILED" failed=$((failed + 1)) - return 1 fi +} - if [ ! -f "${SFETCH_MINISIGN_PUB}" ]; then - echo "error: SFETCH_MINISIGN_PUB=${SFETCH_MINISIGN_PUB} not found" >&2 +# Required: missing signature is a hard failure (installer only). +verify_minisign_required() { + local target="$1" + local base="${DIR}/${target}" + local sig="${base}.minisig" + + if [ ! -f "${base}" ]; then + echo "❌ required file missing: ${target}" failed=$((failed + 1)) return 1 fi - if ! command -v minisign >/dev/null 2>&1; then - echo "error: minisign not found in PATH" >&2 + if [ ! -f "${sig}" ]; then + # POLICY: installer signature is required — no skip-on-missing. + # (Manifests may still skip via verify_minisign_optional; do not merge.) + echo "❌ required minisign signature missing: ${target}.minisig" + echo " Release is incomplete until the installer is signed (make release-sign)." failed=$((failed + 1)) return 1 fi - echo "🔍 [minisign] Verifying ${manifest}" + require_minisign_pub || return 1 + require_minisign_tool || return 1 + + echo "🔍 [minisign] Verifying ${target} (required)" if minisign -V -p "${SFETCH_MINISIGN_PUB}" -m "${base}"; then - echo "✅ ${manifest}.minisig verified" + echo "✅ ${target}.minisig verified" verified=$((verified + 1)) else - echo "❌ ${manifest}.minisig verification FAILED" + echo "❌ ${target}.minisig verification FAILED" failed=$((failed + 1)) fi } @@ -94,8 +145,11 @@ verify_pgp() { echo "Verifying release signatures in ${DIR}..." echo "" -verify_minisign "SHA256SUMS" -verify_minisign "SHA512SUMS" +# Required first so a missing installer signature fails before optional skips. +verify_minisign_required "install-sfetch.sh" + +verify_minisign_optional "SHA256SUMS" +verify_minisign_optional "SHA512SUMS" echo "" From 69b94fc59e8b93433378d6b36c445b8bd1e290d4 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 12:25:19 -0400 Subject: [PATCH 02/14] fix: harden verified bootstrap contract for panel review Colocate the bootstrap engine under the action path (no workspace fallback). Always download hash-pinned minisign 0.12; scrub test-only env in the action. Exact version token matching and required route logging. Remove best-effort self-close. Replace floating latest CI examples; pin Windows dogfood minisign via official archives. Expand bootstrap and signature harnesses (positive minisig/sha256sums fixtures, hostile workspace resolve, goneat fail-closed, PGP manifests-only). Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- .github/actions/setup-sfetch/action.yml | 99 +++- .../setup-sfetch/bootstrap-sfetch-verified.sh | 527 ++++++++++++++++++ .github/workflows/ci.yml | 40 +- docs/cicd-usage-guide.md | 128 +++-- scripts/acquire-minisign-pinned.sh | 212 +++++++ scripts/bootstrap-sfetch-verified.sh | 81 ++- scripts/test-bootstrap-sfetch-verified.sh | 362 +++++++++--- scripts/test-release-verify-signatures.sh | 41 ++ scripts/version-matches-pin.sh | 37 ++ 9 files changed, 1338 insertions(+), 189 deletions(-) create mode 100755 .github/actions/setup-sfetch/bootstrap-sfetch-verified.sh create mode 100755 scripts/acquire-minisign-pinned.sh create mode 100755 scripts/version-matches-pin.sh diff --git a/.github/actions/setup-sfetch/action.yml b/.github/actions/setup-sfetch/action.yml index 8b399ac..7a31fea 100644 --- a/.github/actions/setup-sfetch/action.yml +++ b/.github/actions/setup-sfetch/action.yml @@ -9,6 +9,8 @@ # Trust model: # Consumers pin this action by commit SHA (not a moving tag). That SHA is the # TCB for the verification engine — same model as actions/checkout. +# The engine script is colocated under GITHUB_ACTION_PATH (this directory). +# Never resolved from GITHUB_WORKSPACE (consumer repo contents cannot replace it). # The sfetch trust anchor is embedded in the engine script; never fetched from # the release being authenticated. # @@ -17,10 +19,11 @@ # < v0.4.11 → signed SHA256SUMS + installer hash # # Fail-closed: no continue-on-error, no || true soft skips, no @latest tools, -# no Go toolchain requirement. +# no Go toolchain requirement, no ambient minisign preference, no workspace +# engine fallback. # # Supported sfetch-version range is declared by the engine revision shipped with -# this action SHA (see scripts/bootstrap-sfetch-verified.sh constants). +# this action SHA (see bootstrap-sfetch-verified.sh constants). name: "Setup sfetch" description: "Install a pinned, minisign-verified sfetch (optional goneat) without pipe-to-bash" author: "3leaps" @@ -65,22 +68,35 @@ runs: run: | set -euo pipefail - # Resolve engine: prefer same-repo scripts/ relative to this action - # (full repo tree is available for monorepo path actions). + # Resolve engine only beneath GITHUB_ACTION_PATH (action-owned TCB). + # Never fall back to GITHUB_WORKSPACE — consumer repo contents must not + # replace the pinned action's verification engine. ACTION_ROOT="${GITHUB_ACTION_PATH}" - ENGINE="${ACTION_ROOT}/../../../scripts/bootstrap-sfetch-verified.sh" - if [ ! -f "${ENGINE}" ]; then - # Fallback: workspace checkout of sfetch (same-repo CI) - if [ -f "${GITHUB_WORKSPACE}/scripts/bootstrap-sfetch-verified.sh" ]; then - ENGINE="${GITHUB_WORKSPACE}/scripts/bootstrap-sfetch-verified.sh" - else - echo "error: bootstrap-sfetch-verified.sh not found relative to action or workspace" >&2 - exit 1 - fi + ENGINE="${ACTION_ROOT}/bootstrap-sfetch-verified.sh" + if [ ! -f "${ENGINE}" ] || [ ! -r "${ENGINE}" ]; then + echo "error: action-owned engine missing or unreadable: ${ENGINE}" >&2 + exit 1 + fi + # Require a regular file (reject unexpected directory / special nodes). + if [ ! -f "${ENGINE}" ] || [ -d "${ENGINE}" ]; then + echo "error: action-owned engine is not a regular file: ${ENGINE}" >&2 + exit 1 fi ENGINE="$(cd "$(dirname "${ENGINE}")" && pwd)/$(basename "${ENGINE}")" + case "${ENGINE}" in + "${ACTION_ROOT}"/* | "$(cd "${ACTION_ROOT}" && pwd)"/*) ;; + *) + echo "error: engine resolved outside GITHUB_ACTION_PATH: ${ENGINE}" >&2 + exit 1 + ;; + esac chmod +x "${ENGINE}" + # Scrub test-only override variables so production action path cannot be + # weakened by ambient job env (pinned archive always; no BASE_URL hijack). + unset SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL + unset SFETCH_BOOTSTRAP_BASE_URL + VERSION="${INPUT_SFETCH_VERSION:-}" if [ -z "${VERSION}" ]; then echo "error: sfetch-version is required" >&2 @@ -119,10 +135,36 @@ runs: cat "${LOG}" >&2 if [ "${RC}" -ne 0 ]; then echo "error: verified bootstrap failed (exit ${RC})" >&2 + rm -f "${LOG}" exit "${RC}" fi - ROUTE="$(grep -E 'route=(minisig|sha256sums)' "${LOG}" | tail -n1 | sed -E 's/.*route=([a-z0-9]+).*/\1/' || true)" + # Require exactly one valid terminal route line (fail closed; no soft empty). + set +e + ROUTE_LINES="$(grep -E 'route=(minisig|sha256sums)([[:space:]]|$)' "${LOG}")" + GREP_RC=$? + set -e + if [ "${GREP_RC}" -ne 0 ] || [ -z "${ROUTE_LINES}" ]; then + echo "error: no route=(minisig|sha256sums) log line from engine" >&2 + rm -f "${LOG}" + exit 1 + fi + ROUTE_COUNT="$(printf '%s\n' "${ROUTE_LINES}" | grep -c .)" + if [ "${ROUTE_COUNT}" -ne 1 ]; then + echo "error: expected exactly one route=(minisig|sha256sums) log line, got ${ROUTE_COUNT}" >&2 + rm -f "${LOG}" + exit 1 + fi + ROUTE="$(printf '%s\n' "${ROUTE_LINES}" | sed -E 's/.*route=(minisig|sha256sums).*/\1/')" + case "${ROUTE}" in + minisig|sha256sums) ;; + *) + echo "error: invalid route value: ${ROUTE}" >&2 + rm -f "${LOG}" + exit 1 + ;; + esac + SFETCH_BIN="$(printf '%s\n' "${OUT}" | awk -F= '/^sfetch-bin=/{print $2; exit}')" GONEAT_BIN="$(printf '%s\n' "${OUT}" | awk -F= '/^goneat-bin=/{print $2; exit}')" rm -f "${LOG}" @@ -132,6 +174,35 @@ runs: exit 1 fi + # Exact version token check on installed binary (reuse engine rules). + REPORT="$("${SFETCH_BIN}" --version 2>&1)" || { + echo "error: sfetch --version failed after bootstrap" >&2 + exit 1 + } + VER_NUM="${VERSION#v}" + ESC="$(printf '%s' "${VER_NUM}" | sed 's/\./\\./g')" + if ! printf '%s\n' "${REPORT}" | grep -Eq "(^|[^0-9])v?${ESC}([^0-9]|$)"; then + echo "error: sfetch version assertion failed: expected ${VERSION}, got: ${REPORT}" >&2 + exit 1 + fi + + if [ -n "${INPUT_GONEAT_VERSION:-}" ]; then + if [ -z "${GONEAT_BIN}" ] || [ ! -f "${GONEAT_BIN}" ]; then + echo "error: goneat was requested but binary path missing after bootstrap" >&2 + exit 1 + fi + GREP="$("${GONEAT_BIN}" version 2>&1 | head -n1)" || { + echo "error: goneat version command failed" >&2 + exit 1 + } + GNUM="${INPUT_GONEAT_VERSION#v}" + GESC="$(printf '%s' "${GNUM}" | sed 's/\./\\./g')" + if ! printf '%s\n' "${GREP}" | grep -Eq "(^|[^0-9])v?${GESC}([^0-9]|$)"; then + echo "error: goneat version assertion failed: expected ${INPUT_GONEAT_VERSION}, got: ${GREP}" >&2 + exit 1 + fi + fi + echo "${DIR}" >> "${GITHUB_PATH}" { echo "sfetch-bin=${SFETCH_BIN}" diff --git a/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh b/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh new file mode 100755 index 0000000..999c8e8 --- /dev/null +++ b/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh @@ -0,0 +1,527 @@ +#!/usr/bin/env bash +# bootstrap-sfetch-verified.sh — verified sfetch install for Makefile / CI +# +# Fail-closed dual-route bootstrap: +# - v0.4.11+ → detached install-sfetch.sh.minisig (3-step) +# - earlier → SHA256SUMS + SHA256SUMS.minisig + installer hash (5-step) +# +# Never falls back from a failed .minisig attempt to checksums. +# Never accepts "latest", branches, or floating refs. +# Trust anchor is embedded here — never fetched from the release being authenticated. +# +# Usage: +# bootstrap-sfetch-verified.sh --version v0.4.11 --dir ~/.local/bin +# bootstrap-sfetch-verified.sh --version v0.4.10 --dir ./bin --goneat-version v0.5.15 +# +# Env (test fixtures only — never set in production/CI action path): +# SFETCH_BOOTSTRAP_BASE_URL Override GitHub download base +# (default: https://github.com/3leaps/sfetch/releases/download) +# SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL If set to 1, use ambient minisign +# already on PATH (unit fixtures only). Production always downloads the +# pinned official 0.12 archive and hash-verifies it. +# +# Makefile consumers outside this repo should retrieve this script at an +# immutable git SHA and verify a pinned SHA-256 of the script before executing +# it (see docs/cicd-usage-guide.md). The composite action trusts the script +# via the pinned action SHA instead (engine is colocated under the action path). +# +set -euo pipefail + +# ----------------------------------------------------------------------------- +# Contract constants (bump deliberately when expanding support) +# ----------------------------------------------------------------------------- +# Supported sfetch-version range for THIS script revision. Outside range ⇒ fail. +# Action SHA identifies this contract; consumers pin the action/script, then a +# version within the range it declares. +readonly SFETCH_BOOTSTRAP_MIN="v0.4.9" +readonly SFETCH_BOOTSTRAP_MAX="v0.4.11" +# First release that publishes install-sfetch.sh.minisig +readonly SFETCH_MINISIG_SINCE="v0.4.11" + +# Embedded trust anchor — must match EmbeddedMinisignPubkey in main.go and +# scripts/install-sfetch.sh. Do NOT fetch sfetch-minisign.pub from the release +# for authentication (circular: same origin as the artifact under test). +# The published .pub is for human out-of-band comparison only. +readonly SFETCH_MINISIGN_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" + +readonly MINISIGN_VERSION_EXPECTED="0.12" +readonly MINISIGN_WIN_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-win64.zip" +readonly MINISIGN_WIN_SHA256="37b600344e20c19314b2e82813db2bfdcc408b77b876f7727889dbd46d539479" +readonly MINISIGN_MAC_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip" +readonly MINISIGN_MAC_SHA256="89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63" +readonly MINISIGN_LINUX_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz" +readonly MINISIGN_LINUX_SHA256="9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73" + +readonly SFETCH_REPO_DEFAULT="3leaps/sfetch" + +# ----------------------------------------------------------------------------- +# Logging / errors +# ----------------------------------------------------------------------------- +log() { printf '%s\n' "$*" >&2; } +die() { + log "error: $*" + exit 1 +} + +# ----------------------------------------------------------------------------- +# Semver helpers (tags must be vMAJOR.MINOR.PATCH, optional -prerelease rejected) +# ----------------------------------------------------------------------------- +is_exact_semver_tag() { + case "$1" in + v[0-9]*.[0-9]*.[0-9]*) + # Reject extra suffix (pre-release / build) and non-numeric parts + [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + ;; + *) return 1 ;; + esac +} + +# Compare two vX.Y.Z tags: echo -1 / 0 / 1 for ab +semver_cmp() { + local a="${1#v}" b="${2#v}" + local a1 a2 a3 b1 b2 b3 + IFS=. read -r a1 a2 a3 <<<"$a" + IFS=. read -r b1 b2 b3 <<<"$b" + if ((a1 != b1)); then + if ((a1 < b1)); then echo -1; else echo 1; fi + return + fi + if ((a2 != b2)); then + if ((a2 < b2)); then echo -1; else echo 1; fi + return + fi + if ((a3 != b3)); then + if ((a3 < b3)); then echo -1; else echo 1; fi + return + fi + echo 0 +} + +semver_ge() { [[ "$(semver_cmp "$1" "$2")" != "-1" ]]; } +semver_le() { [[ "$(semver_cmp "$1" "$2")" != "1" ]]; } + +# ----------------------------------------------------------------------------- +# Args +# ----------------------------------------------------------------------------- +VERSION="" +INSTALL_DIR="" +GONEAT_VERSION="" +REPO="${SFETCH_REPO_DEFAULT}" +usage() { + cat <<'EOF' >&2 +Usage: bootstrap-sfetch-verified.sh --version vX.Y.Z --dir PATH [options] + +Required: + --version TAG Exact immutable tag (e.g. v0.4.11). Rejects latest/branches. + --dir PATH Install directory for sfetch (and optional goneat) + +Optional: + --goneat-version TAG Exact goneat tag to install via verified sfetch + --repo owner/name GitHub repo (default: 3leaps/sfetch) + --yes Non-interactive (always on for this script; accepted for CLI parity) + -h, --help Show help + +Env: + SFETCH_BOOTSTRAP_BASE_URL Override download base (tests) +EOF + exit 2 +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || die "--version requires an argument" + VERSION="$2" + shift 2 + ;; + --dir) + [ $# -ge 2 ] || die "--dir requires an argument" + INSTALL_DIR="$2" + shift 2 + ;; + --goneat-version) + [ $# -ge 2 ] || die "--goneat-version requires an argument" + GONEAT_VERSION="$2" + shift 2 + ;; + --repo) + [ $# -ge 2 ] || die "--repo requires an argument" + REPO="$2" + shift 2 + ;; + --yes) + # Accepted for CLI parity with install-sfetch.sh (always non-interactive). + shift + ;; + -h | --help) + usage + ;; + *) + die "unknown option: $1 (see --help)" + ;; + esac +done + +[ -n "$VERSION" ] || die "--version is required" +[ -n "$INSTALL_DIR" ] || die "--dir is required" + +# Reject floating / non-immutable refs (CI and Makefile must never use latest). +case "$VERSION" in + "" | latest | LATEST | main | master | HEAD) + die "refusing floating version ${VERSION:-}; pin an exact tag (e.g. v0.4.11)" + ;; +esac +if ! is_exact_semver_tag "$VERSION"; then + die "version must be exact vMAJOR.MINOR.PATCH (got: $VERSION)" +fi +if [ -n "$GONEAT_VERSION" ]; then + case "$GONEAT_VERSION" in + "" | latest | LATEST | main | master | HEAD) + die "refusing floating goneat-version: ${GONEAT_VERSION:-}" + ;; + esac + if ! is_exact_semver_tag "$GONEAT_VERSION"; then + die "goneat-version must be exact vMAJOR.MINOR.PATCH (got: $GONEAT_VERSION)" + fi +fi + +if ! semver_ge "$VERSION" "$SFETCH_BOOTSTRAP_MIN" || ! semver_le "$VERSION" "$SFETCH_BOOTSTRAP_MAX"; then + die "sfetch-version $VERSION outside supported range ${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX} for this bootstrap revision" +fi + +BASE_URL="${SFETCH_BOOTSTRAP_BASE_URL:-https://github.com/${REPO}/releases/download}" +ASSET_BASE="${BASE_URL}/${VERSION}" + +# Route selection (logged; no silent downgrade between routes) +ROUTE="" +if semver_ge "$VERSION" "$SFETCH_MINISIG_SINCE"; then + ROUTE="minisig" +else + ROUTE="sha256sums" +fi +log "bootstrap-sfetch-verified: version=${VERSION} route=${ROUTE} range=${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX}" + +# ----------------------------------------------------------------------------- +# Platform +# ----------------------------------------------------------------------------- +detect_os() { + case "$(uname -s 2>/dev/null || echo unknown)" in + Linux*) echo linux ;; + Darwin*) echo darwin ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) echo windows ;; + *) + # GitHub Actions Windows often reports MINGW via bash + if [ -n "${RUNNER_OS:-}" ]; then + case "${RUNNER_OS}" in + Windows) echo windows ;; + Linux) echo linux ;; + macOS) echo darwin ;; + *) die "unsupported OS: ${RUNNER_OS}" ;; + esac + else + die "unsupported OS: $(uname -s 2>/dev/null || echo unknown)" + fi + ;; + esac +} + +detect_arch() { + # Prefer GitHub runner arch when present (handles Windows ARM64 host quirks) + if [ -n "${RUNNER_ARCH:-}" ]; then + case "${RUNNER_ARCH}" in + X64 | x64 | AMD64 | amd64) + echo x86_64 + return + ;; + ARM64 | arm64) + echo aarch64 + return + ;; + *) die "unsupported RUNNER_ARCH: ${RUNNER_ARCH}" ;; + esac + fi + local m + m="$(uname -m 2>/dev/null || echo unknown)" + case "$m" in + x86_64 | amd64) echo x86_64 ;; + aarch64 | arm64) echo aarch64 ;; + *) die "unsupported architecture: $m" ;; + esac +} + +OS="$(detect_os)" +ARCH="$(detect_arch)" +log "platform: os=${OS} arch=${ARCH}" + +# macOS Intel: upstream 0.12 macOS archive is arm64-only — fail closed. +if [ "$OS" = "darwin" ] && [ "$ARCH" = "x86_64" ]; then + die "macOS x86_64 is not supported by the pinned minisign 0.12 macOS archive (arm64-only); use an arm64 runner" +fi + +# ----------------------------------------------------------------------------- +# Temp workspace (private; cleaned on exit) +# ----------------------------------------------------------------------------- +WORK="$(mktemp -d "${TMPDIR:-/tmp}/sfetch-bootstrap.XXXXXX")" +cleanup() { + rm -rf "${WORK}" +} +trap cleanup EXIT + +mkdir -p "$INSTALL_DIR" +INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" + +# ----------------------------------------------------------------------------- +# HTTPS fetch with bounded retries +# ----------------------------------------------------------------------------- +http_get() { + local url="$1" out="$2" + local attempt=1 max=4 + while [ "$attempt" -le "$max" ]; do + if command -v curl >/dev/null 2>&1; then + if curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 15 --max-time 120 \ + -o "$out" "$url"; then + return 0 + fi + elif command -v wget >/dev/null 2>&1; then + if wget -q -O "$out" "$url"; then + return 0 + fi + else + die "curl or wget is required" + fi + log "fetch attempt ${attempt}/${max} failed: $url" + attempt=$((attempt + 1)) + sleep 1 + done + die "failed to fetch after ${max} attempts: $url" +} + +sha256_file() { + local f="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$f" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$f" | awk '{print $1}' + else + die "shasum or sha256sum required" + fi +} + +assert_sha256() { + local f="$1" expect="$2" + local got + got="$(sha256_file "$f")" + if [ "$got" != "$expect" ]; then + die "SHA-256 mismatch for $(basename "$f"): expected ${expect}, got ${got}" + fi +} + +# ----------------------------------------------------------------------------- +# Minisign acquisition (pinned official archive only; never prefer ambient PATH) +# ----------------------------------------------------------------------------- +MINISIGN_BIN="" + +# Exact version token match (same rules as scripts/version-matches-pin.sh). +version_output_matches_pin() { + local out="$1" pin="$2" + local ver="${pin#v}" + local esc + esc="$(printf '%s' "$ver" | sed 's/\./\\./g')" + printf '%s\n' "$out" | grep -Eq "(^|[^0-9])v?${esc}([^0-9]|$)" +} + +ensure_minisign() { + # Test-only seam: ambient minisign with exact version identity. + # Production and the composite action never set this (action scrubs it). + if [ "${SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL:-0}" = "1" ]; then + command -v minisign >/dev/null 2>&1 || + die "minisign required on PATH when SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1" + MINISIGN_BIN="$(command -v minisign)" + assert_minisign_version + log "using ambient minisign (test seam SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1): ${MINISIGN_BIN}" + return 0 + fi + + # Always download + hash-verify the pinned upstream archive. + # Do not prefer ambient PATH minisign (PATH shims must not become the verifier). + local tools="${WORK}/tools" + mkdir -p "$tools" + case "$OS" in + windows) + local zip="${WORK}/minisign-win.zip" + http_get "$MINISIGN_WIN_URL" "$zip" + assert_sha256 "$zip" "$MINISIGN_WIN_SHA256" + if command -v unzip >/dev/null 2>&1; then + unzip -q -o "$zip" -d "${WORK}/minisign-extract" + else + # PowerShell Expand-Archive on Windows runners + powershell.exe -NoProfile -Command \ + "Expand-Archive -LiteralPath '$zip' -DestinationPath '${WORK}/minisign-extract' -Force" || + die "failed to extract minisign zip" + fi + local sub + case "$ARCH" in + x86_64) sub="x86_64" ;; + aarch64) sub="aarch64" ;; + *) die "unsupported Windows arch: $ARCH" ;; + esac + local src="${WORK}/minisign-extract/minisign-win64/${sub}/minisign.exe" + [ -f "$src" ] || die "minisign.exe not found at $src" + cp "$src" "${tools}/minisign.exe" + MINISIGN_BIN="${tools}/minisign.exe" + ;; + darwin) + local zip="${WORK}/minisign-mac.zip" + http_get "$MINISIGN_MAC_URL" "$zip" + assert_sha256 "$zip" "$MINISIGN_MAC_SHA256" + unzip -q -o "$zip" -d "${WORK}/minisign-extract" + [ -f "${WORK}/minisign-extract/minisign" ] || die "minisign binary missing from macOS archive" + cp "${WORK}/minisign-extract/minisign" "${tools}/minisign" + chmod 0755 "${tools}/minisign" + MINISIGN_BIN="${tools}/minisign" + ;; + linux) + local tgz="${WORK}/minisign-linux.tar.gz" + http_get "$MINISIGN_LINUX_URL" "$tgz" + assert_sha256 "$tgz" "$MINISIGN_LINUX_SHA256" + mkdir -p "${WORK}/minisign-extract" + tar -xzf "$tgz" -C "${WORK}/minisign-extract" + local sub + case "$ARCH" in + x86_64) sub="x86_64" ;; + aarch64) sub="aarch64" ;; + *) die "unsupported Linux arch: $ARCH" ;; + esac + local src="${WORK}/minisign-extract/minisign-linux/${sub}/minisign" + [ -f "$src" ] || die "minisign not found at $src" + cp "$src" "${tools}/minisign" + chmod 0755 "${tools}/minisign" + MINISIGN_BIN="${tools}/minisign" + ;; + *) + die "unsupported OS for minisign install: $OS" + ;; + esac + assert_minisign_version + log "minisign ready (pinned archive): ${MINISIGN_BIN}" +} + +assert_minisign_version() { + local out + out="$("$MINISIGN_BIN" -v 2>&1 | head -n1 || true)" + version_output_matches_pin "$out" "$MINISIGN_VERSION_EXPECTED" || + die "minisign version assertion failed (want exact ${MINISIGN_VERSION_EXPECTED}): ${out}" +} + +write_pubkey() { + local path="$1" + # minisign -p expects a file with optional comment + RW... key line + { + echo "untrusted comment: sfetch release signing key (embedded trust anchor)" + echo "${SFETCH_MINISIGN_PUBKEY}" + } >"$path" +} + +# ----------------------------------------------------------------------------- +# Verification routes +# ----------------------------------------------------------------------------- +verify_installer_minisig() { + local script="$1" + local _sig="$2" + local pub="$3" + log "verify route=minisig: minisign -Vm install-sfetch.sh" + if ! "$MINISIGN_BIN" -Vm "$script" -p "$pub" -x "$_sig" >/dev/null; then + die "install-sfetch.sh.minisig verification failed (route=minisig; no fallback)" + fi + log "install-sfetch.sh.minisig: OK" +} + +verify_installer_sha256sums() { + local script="$1" sums="$2" sums_sig="$3" pub="$4" + log "verify route=sha256sums: minisign -Vm SHA256SUMS then hash install-sfetch.sh" + if ! "$MINISIGN_BIN" -Vm "$sums" -p "$pub" -x "$sums_sig" >/dev/null; then + die "SHA256SUMS.minisig verification failed (route=sha256sums; no fallback)" + fi + local expect got + expect="$(awk '$2 == "install-sfetch.sh" { print $1; exit }' "$sums")" + [ -n "$expect" ] || die "install-sfetch.sh not listed in SHA256SUMS" + got="$(sha256_file "$script")" + if [ "$got" != "$expect" ]; then + die "install-sfetch.sh SHA-256 mismatch: expected ${expect}, got ${got}" + fi + log "install-sfetch.sh SHA-256 via signed SHA256SUMS: OK" +} + +# ----------------------------------------------------------------------------- +# Install sfetch +# ----------------------------------------------------------------------------- +ensure_minisign + +PUB="${WORK}/sfetch-minisign.pub" +write_pubkey "$PUB" + +SCRIPT="${WORK}/install-sfetch.sh" +http_get "${ASSET_BASE}/install-sfetch.sh" "$SCRIPT" +chmod 0755 "$SCRIPT" + +case "$ROUTE" in + minisig) + SIG="${WORK}/install-sfetch.sh.minisig" + http_get "${ASSET_BASE}/install-sfetch.sh.minisig" "$SIG" + verify_installer_minisig "$SCRIPT" "$SIG" "$PUB" + ;; + sha256sums) + SUMS="${WORK}/SHA256SUMS" + SUMS_SIG="${WORK}/SHA256SUMS.minisig" + http_get "${ASSET_BASE}/SHA256SUMS" "$SUMS" + http_get "${ASSET_BASE}/SHA256SUMS.minisig" "$SUMS_SIG" + verify_installer_sha256sums "$SCRIPT" "$SUMS" "$SUMS_SIG" "$PUB" + ;; + *) + die "internal error: unknown route $ROUTE" + ;; +esac + +# Execute only after verification (never pipe curl | bash) +log "executing verified installer for ${VERSION} → ${INSTALL_DIR}" +# shellcheck disable=SC2086 +bash "$SCRIPT" --tag "$VERSION" --dir "$INSTALL_DIR" --yes --require-minisign + +SFETCH_BIN="${INSTALL_DIR}/sfetch" +if [ "$OS" = "windows" ]; then + if [ -f "${INSTALL_DIR}/sfetch.exe" ]; then + SFETCH_BIN="${INSTALL_DIR}/sfetch.exe" + fi +fi +[ -x "$SFETCH_BIN" ] || [ -f "$SFETCH_BIN" ] || die "sfetch binary missing after install: $SFETCH_BIN" + +# Exact version assertion (token boundary; not soft substring/regex) +REPORT="$("$SFETCH_BIN" --version 2>&1)" || die "sfetch --version failed after install" +log "sfetch reports: ${REPORT}" +version_output_matches_pin "$REPORT" "$VERSION" || + die "sfetch version assertion failed: expected ${VERSION}, got: ${REPORT}" + +# Optional goneat via verified sfetch (no Go toolchain). Fail closed: any +# install or version failure is non-zero (no soft skip when requested). +if [ -n "$GONEAT_VERSION" ]; then + log "installing goneat ${GONEAT_VERSION} via verified sfetch" + "$SFETCH_BIN" --repo fulmenhq/goneat --tag "$GONEAT_VERSION" \ + --dest-dir "$INSTALL_DIR" --require-minisign || + die "goneat install failed for ${GONEAT_VERSION} (requested; fail closed)" + GONEAT_BIN="${INSTALL_DIR}/goneat" + if [ "$OS" = "windows" ] && [ -f "${INSTALL_DIR}/goneat.exe" ]; then + GONEAT_BIN="${INSTALL_DIR}/goneat.exe" + fi + [ -f "$GONEAT_BIN" ] || die "goneat binary missing after install" + GREP="$("$GONEAT_BIN" version 2>&1 | head -n1)" || die "goneat version command failed" + version_output_matches_pin "$GREP" "$GONEAT_VERSION" || + die "goneat version assertion failed: expected ${GONEAT_VERSION}, got: ${GREP}" + log "goneat OK: ${GREP}" +fi + +log "bootstrap-sfetch-verified complete: sfetch=${VERSION} route=${ROUTE} dir=${INSTALL_DIR}" +# Emit install path for action consumers (stdout only machine line) +printf 'sfetch-bin=%s\n' "$SFETCH_BIN" +if [ -n "$GONEAT_VERSION" ]; then + printf 'goneat-bin=%s\n' "$GONEAT_BIN" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7065a8a..986849e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,7 +106,7 @@ jobs: sfetch --version | tee /tmp/sfetch-ver.txt grep -E '0\.4\.10' /tmp/sfetch-ver.txt - - name: Fail-closed optional tool (goneat not requested; deliberate missing assert) + - name: Fail-closed optional tool (goneat not requested) shell: bash run: | set -euo pipefail @@ -117,6 +117,21 @@ jobs: echo "goneat absent as expected when goneat-version omitted" fi + - name: Fail-closed when requested goneat is unavailable + shell: bash + run: | + set -euo pipefail + # Request a non-existent goneat tag; engine must exit non-zero (no soft skip). + if ./scripts/bootstrap-sfetch-verified.sh \ + --version v0.4.10 \ + --dir "$RUNNER_TEMP/goneat-fail" \ + --goneat-version v0.0.0 \ + --yes; then + echo "error: requested unavailable goneat must fail closed" >&2 + exit 1 + fi + echo "requested-goneat failure path OK" + - name: Reject floating sfetch-version shell: bash run: | @@ -181,10 +196,13 @@ jobs: with: go-version: '1.26.5' - - name: Install minisign - shell: pwsh + # Pinned official minisign 0.12 archive (no Chocolatey/winget). + - name: Install pinned minisign 0.12 + shell: bash run: | - choco install minisign -y --no-progress + set -euo pipefail + ./scripts/acquire-minisign-pinned.sh --dir "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Build sfetch shell: pwsh @@ -212,15 +230,13 @@ jobs: with: go-version: '1.26.5' - - name: Install minisign - shell: pwsh + # Pinned official minisign 0.12 archive (no Chocolatey/winget). + - name: Install pinned minisign 0.12 + shell: bash run: | - if (Get-Command choco -ErrorAction SilentlyContinue) { - choco install minisign -y --no-progress - } else { - winget install -e --id FrankDenis.Minisign --silent ` - --accept-source-agreements --accept-package-agreements - } + set -euo pipefail + ./scripts/acquire-minisign-pinned.sh --dir "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Build sfetch shell: pwsh diff --git a/docs/cicd-usage-guide.md b/docs/cicd-usage-guide.md index 0fa5c1a..14d4bd5 100644 --- a/docs/cicd-usage-guide.md +++ b/docs/cicd-usage-guide.md @@ -87,50 +87,47 @@ bash /tmp/bootstrap-sfetch-verified.sh --version v0.4.11 --dir "$HOME/.local/bin still use `install-sfetch.sh` from a pinned tag or, carefully, from `latest`; CI and Makefile recipes must use exact tags. -### Legacy pipe-to-bash (still works; not recommended) +### Makefile / shell (immutable tag + verified engine) -```yaml -- name: Install sfetch + tool (legacy) - env: - GITHUB_TOKEN: ${{ github.token }} - GH_TOKEN: ${{ github.token }} - SFETCH_GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" +For non-Actions consumers, retrieve the engine at an immutable SHA, verify its +digest, then run it with an exact tag (never `latest`): + +```bash +set -euo pipefail +BIN_DIR="$HOME/.local/bin" +mkdir -p "$BIN_DIR" +export PATH="$BIN_DIR:$PATH" - # Prefer a pinned tag. Avoid releases/latest in CI. - SFETCH_VERSION="v0.4.10" - curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${SFETCH_VERSION}/install-sfetch.sh" \ - | bash -s -- --yes --dir "$BIN_DIR" --tag "$SFETCH_VERSION" --require-minisign - export PATH="$BIN_DIR:$PATH" +# Replace and with values published for the release you trust. +SCRIPT_URL="https://raw.githubusercontent.com/3leaps/sfetch//scripts/bootstrap-sfetch-verified.sh" +SCRIPT_SHA256="" +curl -fsSL "$SCRIPT_URL" -o /tmp/bootstrap-sfetch-verified.sh +echo "${SCRIPT_SHA256} /tmp/bootstrap-sfetch-verified.sh" | shasum -a 256 -c +bash /tmp/bootstrap-sfetch-verified.sh --version v0.4.11 --dir "$BIN_DIR" - sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$BIN_DIR" --require-minisign +export GITHUB_TOKEN="${GITHUB_TOKEN:-}" GH_TOKEN="${GH_TOKEN:-}" SFETCH_GITHUB_TOKEN="${SFETCH_GITHUB_TOKEN:-}" +sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$BIN_DIR" --require-minisign ``` Exporting all three token variables at job or workflow scope keeps `sfetch`, `gh`, and child processes on authenticated GitHub API requests by default. -### With explicit version pinning (legacy installer) +### Backward pin (v0.4.10 still on SHA256SUMS route) ```yaml -- name: Install tools (pinned versions) - run: | - set -euo pipefail - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" - export PATH="$BIN_DIR:$PATH" - - SFETCH_VERSION="v0.4.10" - curl -sSfL "https://github.com/3leaps/sfetch/releases/download/${SFETCH_VERSION}/install-sfetch.sh" \ - | bash -s -- --yes --dir "$BIN_DIR" --tag "$SFETCH_VERSION" --require-minisign - - sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$BIN_DIR" --require-minisign +- uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.10 # pre-minisig release; engine selects sha256sums + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + SFETCH_GITHUB_TOKEN: ${{ github.token }} ``` ### Container jobs -When running in a container (e.g., with `container:` in GitHub Actions), the same approach works: +When running in a container (e.g., with `container:` in GitHub Actions), prefer +the composite action when the runner can reach it; otherwise use the verified +engine at an immutable tag (never `releases/latest` in CI): ```yaml jobs: @@ -140,13 +137,27 @@ jobs: image: golang:1.23 steps: - uses: actions/checkout@v4 - - name: Install tools + - uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + SFETCH_GITHUB_TOKEN: ${{ github.token }} + - name: Use sfetch run: | - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" - curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash -s -- --yes --dir "$BIN_DIR" - export PATH="$BIN_DIR:$PATH" - sfetch --repo owner/repo --latest --dest-dir "$BIN_DIR" + sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$HOME/.local/bin" --require-minisign +``` + +Makefile / shell alternative inside the container (pinned tag + verified engine): + +```bash +SFETCH_VERSION="v0.4.11" +SCRIPT_URL="https://raw.githubusercontent.com/3leaps/sfetch//scripts/bootstrap-sfetch-verified.sh" +SCRIPT_SHA256="" +curl -fsSL "$SCRIPT_URL" -o /tmp/bootstrap-sfetch-verified.sh +echo "${SCRIPT_SHA256} /tmp/bootstrap-sfetch-verified.sh" | shasum -a 256 -c +bash /tmp/bootstrap-sfetch-verified.sh --version "${SFETCH_VERSION}" --dir "$HOME/.local/bin" ``` ### Non-root container users @@ -163,13 +174,16 @@ jobs: options: --user 1001 steps: - uses: actions/checkout@v4 - - name: Install tools + - uses: 3leaps/sfetch/.github/actions/setup-sfetch@ + with: + sfetch-version: v0.4.11 + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + SFETCH_GITHUB_TOKEN: ${{ github.token }} + - name: Use sfetch run: | - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" - curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash -s -- --yes --dir "$BIN_DIR" - export PATH="$BIN_DIR:$PATH" - sfetch --repo owner/repo --latest --dest-dir "$BIN_DIR" + sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$HOME/.local/bin" --require-minisign ``` If you need to install system packages (e.g., `apt-get install`), you may temporarily need root access: @@ -206,15 +220,26 @@ Or use POSIX-compatible options only: ## GitLab CI Example +CI must use an immutable tag and the verified engine (never `releases/latest`): + ```yaml install-tools: image: golang:1.23 + variables: + SFETCH_VERSION: "v0.4.11" + # Pin the engine script to an immutable git SHA and its digest. + SFETCH_ENGINE_SHA: "" + SFETCH_ENGINE_SHA256: "" script: - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" - - curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash -s -- --yes --dir "$BIN_DIR" + - | + curl -fsSL "https://raw.githubusercontent.com/3leaps/sfetch/${SFETCH_ENGINE_SHA}/scripts/bootstrap-sfetch-verified.sh" \ + -o /tmp/bootstrap-sfetch-verified.sh + echo "${SFETCH_ENGINE_SHA256} /tmp/bootstrap-sfetch-verified.sh" | shasum -a 256 -c + bash /tmp/bootstrap-sfetch-verified.sh --version "${SFETCH_VERSION}" --dir "$BIN_DIR" - export PATH="$BIN_DIR:$PATH" - - sfetch --repo owner/repo --latest --dest-dir "$BIN_DIR" --require-minisign + - sfetch --repo owner/repo --tag v1.2.3 --dest-dir "$BIN_DIR" --require-minisign ``` ## Cache Directory @@ -332,11 +357,14 @@ installs or asserts **minisign 0.12** from official jedisct1 release archives Windows maps `RUNNER_ARCH` X64 → `x86_64`, ARM64 → `aarch64`. **macOS Intel is not supported** by the upstream 0.12 macOS archive (arm64-only) and fails -closed unless an ambient minisign 0.12 is already on PATH. - -Do **not** use Chocolatey/winget community packages for the verified bootstrap -path. Distro packages (apt/brew) are acceptable only when the binary reports -exactly version 0.12 (the engine re-asserts identity after acquisition). +closed. + +Production and the composite action **always** download and hash-verify the +pinned official archive. They never prefer ambient PATH minisign (a PATH shim +must not become the verifier). Do **not** use Chocolatey/winget community +packages for the verified bootstrap path. A test-only seam +(`SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1`) exists for local fixtures and is +scrubbed by the action. ## Incomplete release window diff --git a/scripts/acquire-minisign-pinned.sh b/scripts/acquire-minisign-pinned.sh new file mode 100755 index 0000000..a8a8f0f --- /dev/null +++ b/scripts/acquire-minisign-pinned.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# acquire-minisign-pinned.sh — install official minisign 0.12 with hash verification. +# +# Usage: acquire-minisign-pinned.sh --dir PATH +# Installs minisign (or minisign.exe on Windows) into PATH directory. +# Always downloads the pinned upstream archive; never prefers ambient PATH tools. +# +# Constants must stay aligned with scripts/bootstrap-sfetch-verified.sh. +set -euo pipefail + +readonly MINISIGN_VERSION_EXPECTED="0.12" +readonly MINISIGN_WIN_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-win64.zip" +readonly MINISIGN_WIN_SHA256="37b600344e20c19314b2e82813db2bfdcc408b77b876f7727889dbd46d539479" +readonly MINISIGN_MAC_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip" +readonly MINISIGN_MAC_SHA256="89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63" +readonly MINISIGN_LINUX_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz" +readonly MINISIGN_LINUX_SHA256="9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73" + +DIR="" +while [ $# -gt 0 ]; do + case "$1" in + --dir) + [ $# -ge 2 ] || { + echo "error: --dir requires an argument" >&2 + exit 1 + } + DIR="$2" + shift 2 + ;; + -h | --help) + echo "Usage: acquire-minisign-pinned.sh --dir PATH" >&2 + exit 0 + ;; + *) + echo "error: unknown option: $1" >&2 + exit 1 + ;; + esac +done + +[ -n "$DIR" ] || { + echo "error: --dir is required" >&2 + exit 1 +} +mkdir -p "$DIR" +DIR="$(cd "$DIR" && pwd)" + +die() { + echo "error: $*" >&2 + exit 1 +} +log() { printf '%s\n' "$*" >&2; } + +detect_os() { + case "$(uname -s 2>/dev/null || echo unknown)" in + Linux*) echo linux ;; + Darwin*) echo darwin ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) echo windows ;; + *) + if [ -n "${RUNNER_OS:-}" ]; then + case "${RUNNER_OS}" in + Windows) echo windows ;; + Linux) echo linux ;; + macOS) echo darwin ;; + *) die "unsupported OS: ${RUNNER_OS}" ;; + esac + else + die "unsupported OS: $(uname -s 2>/dev/null || echo unknown)" + fi + ;; + esac +} + +detect_arch() { + if [ -n "${RUNNER_ARCH:-}" ]; then + case "${RUNNER_ARCH}" in + X64 | x64 | AMD64 | amd64) + echo x86_64 + return + ;; + ARM64 | arm64) + echo aarch64 + return + ;; + *) die "unsupported RUNNER_ARCH: ${RUNNER_ARCH}" ;; + esac + fi + local m + m="$(uname -m 2>/dev/null || echo unknown)" + case "$m" in + x86_64 | amd64) echo x86_64 ;; + aarch64 | arm64) echo aarch64 ;; + *) die "unsupported architecture: $m" ;; + esac +} + +http_get() { + local url="$1" out="$2" + local attempt=1 max=4 + while [ "$attempt" -le "$max" ]; do + if command -v curl >/dev/null 2>&1; then + if curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 15 --max-time 120 \ + -o "$out" "$url"; then + return 0 + fi + elif command -v wget >/dev/null 2>&1; then + if wget -q -O "$out" "$url"; then + return 0 + fi + else + die "curl or wget is required" + fi + log "fetch attempt ${attempt}/${max} failed: $url" + attempt=$((attempt + 1)) + sleep 1 + done + die "failed to fetch after ${max} attempts: $url" +} + +sha256_file() { + local f="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$f" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$f" | awk '{print $1}' + else + die "shasum or sha256sum required" + fi +} + +assert_sha256() { + local f="$1" expect="$2" + local got + got="$(sha256_file "$f")" + if [ "$got" != "$expect" ]; then + die "SHA-256 mismatch for $(basename "$f"): expected ${expect}, got ${got}" + fi +} + +OS="$(detect_os)" +ARCH="$(detect_arch)" + +if [ "$OS" = "darwin" ] && [ "$ARCH" = "x86_64" ]; then + die "macOS x86_64 is not supported by the pinned minisign 0.12 macOS archive (arm64-only)" +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/minisign-acquire.XXXXXX")" +cleanup() { rm -rf "${WORK}"; } +trap cleanup EXIT + +case "$OS" in + windows) + zip="${WORK}/minisign-win.zip" + http_get "$MINISIGN_WIN_URL" "$zip" + assert_sha256 "$zip" "$MINISIGN_WIN_SHA256" + if command -v unzip >/dev/null 2>&1; then + unzip -q -o "$zip" -d "${WORK}/extract" + else + powershell.exe -NoProfile -Command \ + "Expand-Archive -LiteralPath '$zip' -DestinationPath '${WORK}/extract' -Force" || + die "failed to extract minisign zip" + fi + case "$ARCH" in + x86_64) sub="x86_64" ;; + aarch64) sub="aarch64" ;; + *) die "unsupported Windows arch: $ARCH" ;; + esac + src="${WORK}/extract/minisign-win64/${sub}/minisign.exe" + [ -f "$src" ] || die "minisign.exe not found at $src" + cp "$src" "${DIR}/minisign.exe" + BIN="${DIR}/minisign.exe" + ;; + darwin) + zip="${WORK}/minisign-mac.zip" + http_get "$MINISIGN_MAC_URL" "$zip" + assert_sha256 "$zip" "$MINISIGN_MAC_SHA256" + unzip -q -o "$zip" -d "${WORK}/extract" + [ -f "${WORK}/extract/minisign" ] || die "minisign binary missing from macOS archive" + cp "${WORK}/extract/minisign" "${DIR}/minisign" + chmod 0755 "${DIR}/minisign" + BIN="${DIR}/minisign" + ;; + linux) + tgz="${WORK}/minisign-linux.tar.gz" + http_get "$MINISIGN_LINUX_URL" "$tgz" + assert_sha256 "$tgz" "$MINISIGN_LINUX_SHA256" + mkdir -p "${WORK}/extract" + tar -xzf "$tgz" -C "${WORK}/extract" + case "$ARCH" in + x86_64) sub="x86_64" ;; + aarch64) sub="aarch64" ;; + *) die "unsupported Linux arch: $ARCH" ;; + esac + src="${WORK}/extract/minisign-linux/${sub}/minisign" + [ -f "$src" ] || die "minisign not found at $src" + cp "$src" "${DIR}/minisign" + chmod 0755 "${DIR}/minisign" + BIN="${DIR}/minisign" + ;; + *) + die "unsupported OS for minisign install: $OS" + ;; +esac + +out="$("$BIN" -v 2>&1 | head -n1 || true)" +# Exact token match for version (not substring) +esc="$(printf '%s' "$MINISIGN_VERSION_EXPECTED" | sed 's/\./\\./g')" +printf '%s\n' "$out" | grep -Eq "(^|[^0-9])${esc}([^0-9]|$)" || + die "minisign version assertion failed (want exact ${MINISIGN_VERSION_EXPECTED}): ${out}" + +log "minisign ready: ${BIN} (${out})" +printf 'minisign-bin=%s\n' "$BIN" diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index 00ae32d..999c8e8 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -13,16 +13,17 @@ # bootstrap-sfetch-verified.sh --version v0.4.11 --dir ~/.local/bin # bootstrap-sfetch-verified.sh --version v0.4.10 --dir ./bin --goneat-version v0.5.15 # -# Env (testing / advanced): +# Env (test fixtures only — never set in production/CI action path): # SFETCH_BOOTSTRAP_BASE_URL Override GitHub download base # (default: https://github.com/3leaps/sfetch/releases/download) -# SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL If set to 1, require ambient minisign -# already on PATH (used by unit fixtures that supply their own binary). +# SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL If set to 1, use ambient minisign +# already on PATH (unit fixtures only). Production always downloads the +# pinned official 0.12 archive and hash-verifies it. # # Makefile consumers outside this repo should retrieve this script at an # immutable git SHA and verify a pinned SHA-256 of the script before executing # it (see docs/cicd-usage-guide.md). The composite action trusts the script -# via the pinned action SHA instead. +# via the pinned action SHA instead (engine is colocated under the action path). # set -euo pipefail @@ -254,7 +255,7 @@ log "platform: os=${OS} arch=${ARCH}" # macOS Intel: upstream 0.12 macOS archive is arm64-only — fail closed. if [ "$OS" = "darwin" ] && [ "$ARCH" = "x86_64" ]; then - die "macOS x86_64 is not supported by the pinned minisign 0.12 macOS archive (arm64-only); use an arm64 runner or install minisign 0.12 out of band and set SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1" + die "macOS x86_64 is not supported by the pinned minisign 0.12 macOS archive (arm64-only); use an arm64 runner" fi # ----------------------------------------------------------------------------- @@ -316,30 +317,33 @@ assert_sha256() { } # ----------------------------------------------------------------------------- -# Minisign acquisition (pinned; Windows uses official archive only) +# Minisign acquisition (pinned official archive only; never prefer ambient PATH) # ----------------------------------------------------------------------------- MINISIGN_BIN="" +# Exact version token match (same rules as scripts/version-matches-pin.sh). +version_output_matches_pin() { + local out="$1" pin="$2" + local ver="${pin#v}" + local esc + esc="$(printf '%s' "$ver" | sed 's/\./\\./g')" + printf '%s\n' "$out" | grep -Eq "(^|[^0-9])v?${esc}([^0-9]|$)" +} + ensure_minisign() { + # Test-only seam: ambient minisign with exact version identity. + # Production and the composite action never set this (action scrubs it). if [ "${SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL:-0}" = "1" ]; then - command -v minisign >/dev/null 2>&1 || die "minisign required on PATH when SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1" + command -v minisign >/dev/null 2>&1 || + die "minisign required on PATH when SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1" MINISIGN_BIN="$(command -v minisign)" assert_minisign_version + log "using ambient minisign (test seam SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1): ${MINISIGN_BIN}" return 0 fi - # Prefer ambient minisign only if it reports the expected version. - if command -v minisign >/dev/null 2>&1; then - local v - v="$(minisign -v 2>&1 | head -n1 || true)" - if echo "$v" | grep -q "${MINISIGN_VERSION_EXPECTED}"; then - MINISIGN_BIN="$(command -v minisign)" - log "using ambient minisign: ${MINISIGN_BIN} (${v})" - return 0 - fi - log "ambient minisign version not ${MINISIGN_VERSION_EXPECTED} (${v}); installing pinned binary" - fi - + # Always download + hash-verify the pinned upstream archive. + # Do not prefer ambient PATH minisign (PATH shims must not become the verifier). local tools="${WORK}/tools" mkdir -p "$tools" case "$OS" in @@ -399,14 +403,14 @@ ensure_minisign() { ;; esac assert_minisign_version - log "minisign ready: ${MINISIGN_BIN}" + log "minisign ready (pinned archive): ${MINISIGN_BIN}" } assert_minisign_version() { local out out="$("$MINISIGN_BIN" -v 2>&1 | head -n1 || true)" - echo "$out" | grep -q "${MINISIGN_VERSION_EXPECTED}" || - die "minisign version assertion failed (want ${MINISIGN_VERSION_EXPECTED}): ${out}" + version_output_matches_pin "$out" "$MINISIGN_VERSION_EXPECTED" || + die "minisign version assertion failed (want exact ${MINISIGN_VERSION_EXPECTED}): ${out}" } write_pubkey() { @@ -491,41 +495,26 @@ if [ "$OS" = "windows" ]; then fi [ -x "$SFETCH_BIN" ] || [ -f "$SFETCH_BIN" ] || die "sfetch binary missing after install: $SFETCH_BIN" -# Exact version assertion -REPORT="$("$SFETCH_BIN" --version 2>&1 || true)" +# Exact version assertion (token boundary; not soft substring/regex) +REPORT="$("$SFETCH_BIN" --version 2>&1)" || die "sfetch --version failed after install" log "sfetch reports: ${REPORT}" -# Accept version with or without leading v in binary output -VER_NUM="${VERSION#v}" -echo "$REPORT" | grep -Eq "${VER_NUM}|${VERSION}" || +version_output_matches_pin "$REPORT" "$VERSION" || die "sfetch version assertion failed: expected ${VERSION}, got: ${REPORT}" -# Optional self-close: re-fetch install-sfetch.sh through sfetch and compare -if "$SFETCH_BIN" --help 2>&1 | grep -q -- '--asset-match' || true; then - if "$SFETCH_BIN" --repo "$REPO" --tag "$VERSION" --asset-match 'install-sfetch.sh' \ - --dest-dir "${WORK}/self-close" --require-minisign 2>/dev/null; then - REFETCH="$(find "${WORK}/self-close" -name 'install-sfetch.sh' 2>/dev/null | head -n1 || true)" - if [ -n "$REFETCH" ] && [ -f "$REFETCH" ]; then - if ! cmp -s "$SCRIPT" "$REFETCH"; then - die "self-close check failed: re-fetched install-sfetch.sh differs from executed script" - fi - log "self-close: re-fetched install-sfetch.sh matches" - fi - fi -fi - -# Optional goneat via verified sfetch (no Go toolchain) +# Optional goneat via verified sfetch (no Go toolchain). Fail closed: any +# install or version failure is non-zero (no soft skip when requested). if [ -n "$GONEAT_VERSION" ]; then log "installing goneat ${GONEAT_VERSION} via verified sfetch" "$SFETCH_BIN" --repo fulmenhq/goneat --tag "$GONEAT_VERSION" \ - --dest-dir "$INSTALL_DIR" --require-minisign + --dest-dir "$INSTALL_DIR" --require-minisign || + die "goneat install failed for ${GONEAT_VERSION} (requested; fail closed)" GONEAT_BIN="${INSTALL_DIR}/goneat" if [ "$OS" = "windows" ] && [ -f "${INSTALL_DIR}/goneat.exe" ]; then GONEAT_BIN="${INSTALL_DIR}/goneat.exe" fi [ -f "$GONEAT_BIN" ] || die "goneat binary missing after install" - GREP="$("$GONEAT_BIN" version 2>&1 | head -n1 || true)" - GNUM="${GONEAT_VERSION#v}" - echo "$GREP" | grep -Eq "${GNUM}|${GONEAT_VERSION}" || + GREP="$("$GONEAT_BIN" version 2>&1 | head -n1)" || die "goneat version command failed" + version_output_matches_pin "$GREP" "$GONEAT_VERSION" || die "goneat version assertion failed: expected ${GONEAT_VERSION}, got: ${GREP}" log "goneat OK: ${GREP}" fi diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index adfaf43..6f5a531 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -5,7 +5,10 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" SCRIPT="${ROOT}/scripts/bootstrap-sfetch-verified.sh" +ACTION_ENGINE="${ROOT}/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh" +VERSION_MATCH="${ROOT}/scripts/version-matches-pin.sh" [ -x "$SCRIPT" ] || chmod +x "$SCRIPT" +[ -x "$VERSION_MATCH" ] || chmod +x "$VERSION_MATCH" fail() { echo "FAIL: $*" >&2 @@ -13,18 +16,21 @@ fail() { } pass() { echo "PASS: $*"; } -# --- Version rejection (no network) --- -reject() { - local args=("$@") - if "$SCRIPT" "${args[@]}" --dir /tmp 2>/dev/null; then - fail "should reject: ${args[*]}" - fi -} +# --- Engine copy identity (action-owned must match scripts/ SSOT) --- +[ -f "$ACTION_ENGINE" ] || fail "action-owned engine missing: $ACTION_ENGINE" +cmp -s "$SCRIPT" "$ACTION_ENGINE" || fail "action engine diverged from scripts/bootstrap-sfetch-verified.sh" +pass "action engine identical to scripts SSOT" -# Missing required args -if "$SCRIPT" 2>/dev/null; then fail "should require --version"; else pass "requires --version"; fi +# --- version-matches-pin exact token semantics --- +"$VERSION_MATCH" "sfetch 0.4.11" "v0.4.11" || fail "should match sfetch 0.4.11" +"$VERSION_MATCH" "sfetch version v0.4.11" "v0.4.11" || fail "should match v-prefixed" +if "$VERSION_MATCH" "sfetch 10x4y110" "v0.4.11"; then fail "must not soft-match 10x4y110"; fi +if "$VERSION_MATCH" "sfetch 10.4.11" "v0.4.11"; then fail "must not match inside 10.4.11"; fi +if "$VERSION_MATCH" "sfetch 0.4.110" "v0.4.11"; then fail "must not match 0.4.110"; fi +pass "version-matches-pin exact token rules" -reject --version latest --dir /tmp && pass "rejects latest" || true +# --- Version rejection (no network) --- +if "$SCRIPT" 2>/dev/null; then fail "should require --version"; else pass "requires --version"; fi if "$SCRIPT" --version latest --dir /tmp 2>/dev/null; then fail "latest should fail"; else pass "rejects latest"; fi if "$SCRIPT" --version main --dir /tmp 2>/dev/null; then fail "main should fail"; else pass "rejects main"; fi if "$SCRIPT" --version v0.4.11-rc1 --dir /tmp 2>/dev/null; then fail "prerelease should fail"; else pass "rejects prerelease"; fi @@ -32,11 +38,18 @@ if "$SCRIPT" --version v0.4.8 --dir /tmp 2>/dev/null; then fail "below min shoul if "$SCRIPT" --version v0.4.12 --dir /tmp 2>/dev/null; then fail "above max should fail"; else pass "rejects above max"; fi # --- Route selection logging via dry parse --- -# Source-compatible check: run with a fake base URL that fails fetch after route log WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-boot-test.XXXXXX")" -trap 'rm -rf "${WORKDIR}"' EXIT +SRV_PID="" +SRV_PID2="" +cleanup_harness() { + [ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true + [ -n "${SRV_PID2:-}" ] && kill "$SRV_PID2" 2>/dev/null || true + wait "$SRV_PID" 2>/dev/null || true + wait "$SRV_PID2" 2>/dev/null || true + rm -rf "${WORKDIR}" +} +trap cleanup_harness EXIT -# Capture route for v0.4.10 set +e OUT1040="$WORKDIR/out410.txt" SFETCH_BOOTSTRAP_BASE_URL="file://${WORKDIR}/empty" \ @@ -53,7 +66,7 @@ set -e grep -q 'route=minisig' "$OUT411" || fail "v0.4.11 should select minisig route (log: $(cat "$OUT411"))" pass "v0.4.11 → route=minisig" -# --- Local dual-route positive fixtures with ephemeral minisign --- +# --- Local dual-route fixtures with ephemeral minisign --- command -v minisign >/dev/null 2>&1 || fail "minisign required" command -v python3 >/dev/null 2>&1 || fail "python3 required for local HTTP fixture" @@ -63,74 +76,71 @@ minisign -G -W -p "$PUB" -s "$KEY" >/dev/null 2>&1 || minisign -G -n -p "$PUB" -s "$KEY" >/dev/null 2>&1 || fail "keygen" -# Build a fake "release" that install script won't fully run — we only test -# verify-before-execute by making install-sfetch.sh a stub that writes a marker -# when executed (proving execution happened only after verify). +# Extract the RW... public key line for embedding +TEST_PUBKEY="$(grep -E '^RW' "$PUB" | head -n1 | tr -d '\r\n')" +[ -n "$TEST_PUBKEY" ] || fail "could not read test pubkey" + +# Patch a temporary engine copy: replace production trust anchor with test key +# (no production override seam — ephemeral patched copy only). +PROD_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" +PATCHED="$WORKDIR/bootstrap-patched.sh" +sed "s|${PROD_PUBKEY}|${TEST_PUBKEY}|g" "$SCRIPT" >"$PATCHED" +chmod +x "$PATCHED" +grep -q "$TEST_PUBKEY" "$PATCHED" || fail "patched engine missing test pubkey" +grep -q "$PROD_PUBKEY" "$PATCHED" && fail "patched engine still has production pubkey" + +# Local HTTP server root +SRV_ROOT="$WORKDIR/www" +mkdir -p "$SRV_ROOT/v0.4.11" "$SRV_ROOT/v0.4.10" + +# Stub installer that creates a fake sfetch reporting the harness version make_stub_installer() { local dest="$1" cat >"$dest" <<'STUB' #!/usr/bin/env bash set -euo pipefail DIR="" +TAG="" while [ $# -gt 0 ]; do case "$1" in --dir) DIR="$2"; shift 2 ;; - --tag|--yes|--require-minisign) shift ;; - --tag|--dir) shift 2 ;; + --tag) TAG="$2"; shift 2 ;; + --yes|--require-minisign) shift ;; *) shift ;; esac done -# crude parse again -while [ $# -gt 0 ]; do shift; done -# re-parse from original not available; write marker using env from harness -: "${HARNESS_INSTALL_DIR:?}" -mkdir -p "${HARNESS_INSTALL_DIR}" -# Fake sfetch binary that reports version from HARNESS_FAKE_VERSION -cat >"${HARNESS_INSTALL_DIR}/sfetch" <"${DIR}/sfetch" <"${HARNESS_INSTALL_DIR}/.stub-ran" +chmod +x "${DIR}/sfetch" +echo "STUB_RAN" >"${DIR}/.stub-ran" STUB chmod +x "$dest" } -# Patch approach: the real bootstrap embeds production pubkey. For fixture -# verification we need the embedded key to match. Instead of rewriting the -# script, test the pure verification helpers via a mini harness that mimics -# the two routes with the production path only for network live tests. -# -# Local fixture: inject via SFETCH_BOOTSTRAP — not available for pubkey override. -# So we only fully exercise network live path for v0.4.10 (real signed release) -# and unit-level route/reject above. Optional live: +make_stub_installer "$SRV_ROOT/v0.4.11/install-sfetch.sh" +make_stub_installer "$SRV_ROOT/v0.4.10/install-sfetch.sh" -# Live install against published v0.4.10 when explicitly enabled or in GHA. -if [ "${SFETCH_BOOTSTRAP_LIVE:-0}" = "1" ] || [ "${GITHUB_ACTIONS:-}" = "true" ]; then - LIVE_DIR="$WORKDIR/live" - mkdir -p "$LIVE_DIR" - if "$SCRIPT" --version v0.4.10 --dir "$LIVE_DIR"; then - "$LIVE_DIR/sfetch" --version | grep -q '0.4.10' || fail "live v0.4.10 version" - pass "live v0.4.10 sha256sums route install" - else - fail "live v0.4.10 install failed" - fi -else - pass "skip live install (set SFETCH_BOOTSTRAP_LIVE=1 or GITHUB_ACTIONS=true to enable)" -fi +# Sign v0.4.11 installer with test key (minisig route) +minisign -S -s "$KEY" -t "test-v0.4.11" -m "$SRV_ROOT/v0.4.11/install-sfetch.sh" -# Negative: no execution-before-verify — supply bad minisig content via local HTTP -# Custom: override base URL to local python server serving unsigned installer -SRV_ROOT="$WORKDIR/www" -mkdir -p "$SRV_ROOT/v0.4.11" -printf '#!/bin/sh\necho SHOULD_NOT_RUN\n' >"$SRV_ROOT/v0.4.11/install-sfetch.sh" -chmod +x "$SRV_ROOT/v0.4.11/install-sfetch.sh" -# Wrong signature: sign with our key but script embeds production key → verify fails -minisign -S -s "$KEY" -t t -m "$SRV_ROOT/v0.4.11/install-sfetch.sh" - -PORT=0 -# shellcheck disable=SC2016 -python3 - "$SRV_ROOT" "$WORKDIR/port" <<'PY' & +# Sign v0.4.10 via SHA256SUMS route +( + cd "$SRV_ROOT/v0.4.10" + shasum -a 256 install-sfetch.sh >SHA256SUMS +) +minisign -S -s "$KEY" -t "test-v0.4.10" -m "$SRV_ROOT/v0.4.10/SHA256SUMS" + +# Start HTTP fixture server without command substitution (bash subshells wait +# on background children, which would hang on serve_forever). +start_http_fixture() { + local root="$1" portfile="$2" + rm -f "$portfile" + python3 - "$root" "$portfile" <<'PY' & import http.server, socketserver, sys, pathlib root = pathlib.Path(sys.argv[1]) portfile = pathlib.Path(sys.argv[2]) @@ -143,27 +153,245 @@ with socketserver.TCPServer(("127.0.0.1", 0), H) as httpd: portfile.write_text(str(httpd.server_address[1])) httpd.serve_forever() PY + # Caller reads $! after this function returns in the same shell. +} + +PORTFILE="$WORKDIR/port" +start_http_fixture "$SRV_ROOT" "$PORTFILE" SRV_PID=$! for _ in $(seq 1 50); do - [ -f "$WORKDIR/port" ] && break + [ -f "$PORTFILE" ] && break sleep 0.05 done -PORT="$(cat "$WORKDIR/port")" +[ -f "$PORTFILE" ] || fail "HTTP fixture server failed to start" +PORT="$(cat "$PORTFILE")" +BASE="http://127.0.0.1:${PORT}" + +# Positive: v0.4.11 minisig route with patched trust anchor +GOOD411="$WORKDIR/good411" +mkdir -p "$GOOD411" +set +e +OUT_GOOD="$WORKDIR/out-good411.txt" +SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ + SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ + "$PATCHED" --version v0.4.11 --dir "$GOOD411" >"$OUT_GOOD" 2>&1 +RC=$? +set -e +[ "$RC" -eq 0 ] || fail "positive v0.4.11 minisig should succeed (log: $(cat "$OUT_GOOD"))" +grep -q 'route=minisig' "$OUT_GOOD" || fail "positive minisig route log missing" +[ -f "$GOOD411/.stub-ran" ] || fail "installer should execute after successful verify" +[ -x "$GOOD411/sfetch" ] || fail "sfetch binary should be installed" +"$GOOD411/sfetch" --version | grep -q '0.4.11' || fail "stub sfetch version" +pass "positive v0.4.11 minisig route (patched ephemeral key)" + +# Positive: v0.4.10 sha256sums route with patched trust anchor +GOOD410="$WORKDIR/good410" +mkdir -p "$GOOD410" +set +e +OUT_GOOD410="$WORKDIR/out-good410.txt" +SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ + SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ + "$PATCHED" --version v0.4.10 --dir "$GOOD410" >"$OUT_GOOD410" 2>&1 +RC=$? +set -e +[ "$RC" -eq 0 ] || fail "positive v0.4.10 sha256sums should succeed (log: $(cat "$OUT_GOOD410"))" +grep -q 'route=sha256sums' "$OUT_GOOD410" || fail "positive sha256sums route log missing" +[ -f "$GOOD410/.stub-ran" ] || fail "installer should execute after sha256sums verify" +pass "positive v0.4.10 sha256sums route (patched ephemeral key)" + +# Negative: wrong-key minisig fails closed without executing installer +# Serve production-key-expected path: use UNPATCHED script against test-key sig BAD_DIR="$WORKDIR/bad" mkdir -p "$BAD_DIR" set +e OUTBAD="$WORKDIR/outbad.txt" -SFETCH_BOOTSTRAP_BASE_URL="http://127.0.0.1:${PORT}" \ +SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ "$SCRIPT" --version v0.4.11 --dir "$BAD_DIR" >"$OUTBAD" 2>&1 RC=$? set -e -kill "$SRV_PID" 2>/dev/null || true -wait "$SRV_PID" 2>/dev/null || true [ "$RC" -ne 0 ] || fail "wrong-key minisig should fail" [ ! -f "$BAD_DIR/.stub-ran" ] || fail "installer must not run before verify" [ ! -f "$BAD_DIR/sfetch" ] || fail "sfetch must not be installed on failed verify" grep -q 'route=minisig' "$OUTBAD" || fail "expected minisig route in log" pass "wrong-key minisig fails closed without executing installer" +# Negative: missing signature asset fails closed +mkdir -p "$SRV_ROOT/v0.4.11-nosig" +cp "$SRV_ROOT/v0.4.11/install-sfetch.sh" "$SRV_ROOT/v0.4.11-nosig/" +# No .minisig +# Re-map by using a version that selects minisig but only has installer — +# supported max is v0.4.11 only, so use empty BASE subdir via another port root. +NOSIG_ROOT="$WORKDIR/www-nosig" +mkdir -p "$NOSIG_ROOT/v0.4.11" +cp "$SRV_ROOT/v0.4.11/install-sfetch.sh" "$NOSIG_ROOT/v0.4.11/" +PORTFILE2="$WORKDIR/port2" +start_http_fixture "$NOSIG_ROOT" "$PORTFILE2" +SRV_PID2=$! +for _ in $(seq 1 50); do + [ -f "$PORTFILE2" ] && break + sleep 0.05 +done +[ -f "$PORTFILE2" ] || fail "second HTTP fixture server failed to start" +PORT2="$(cat "$PORTFILE2")" +NOSIG_DIR="$WORKDIR/nosig" +mkdir -p "$NOSIG_DIR" +set +e +OUTNOSIG="$WORKDIR/out-nosig.txt" +SFETCH_BOOTSTRAP_BASE_URL="http://127.0.0.1:${PORT2}" \ + SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ + "$PATCHED" --version v0.4.11 --dir "$NOSIG_DIR" >"$OUTNOSIG" 2>&1 +RC=$? +set -e +[ "$RC" -ne 0 ] || fail "missing minisig should fail" +[ ! -f "$NOSIG_DIR/.stub-ran" ] || fail "installer must not run when sig missing" +pass "missing install-sfetch.sh.minisig fails closed" + +# --- Action engine resolution simulation (no GITHUB_WORKSPACE fallback) --- +# Simulate: GITHUB_ACTION_PATH has real engine; workspace has hostile same-named script. +HOSTILE_WS="$WORKDIR/hostile-ws" +mkdir -p "$HOSTILE_WS/scripts" "$WORKDIR/action-path" +cp "$ACTION_ENGINE" "$WORKDIR/action-path/bootstrap-sfetch-verified.sh" +cat >"$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" <<'HOSTILE' +#!/usr/bin/env bash +echo "HOSTILE_ENGINE_EXECUTED" >&2 +echo "route=minisig" >&2 +echo "sfetch-bin=/tmp/evil" +exit 0 +HOSTILE +chmod +x "$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" \ + "$WORKDIR/action-path/bootstrap-sfetch-verified.sh" + +# Resolution logic mirrored from action.yml (must only use ACTION_PATH) +resolve_engine() { + local ACTION_ROOT="$1" + local ENGINE="${ACTION_ROOT}/bootstrap-sfetch-verified.sh" + if [ ! -f "${ENGINE}" ] || [ ! -r "${ENGINE}" ]; then + echo "error: action-owned engine missing" >&2 + return 1 + fi + ENGINE="$(cd "$(dirname "${ENGINE}")" && pwd)/$(basename "${ENGINE}")" + case "${ENGINE}" in + "${ACTION_ROOT}"/* | "$(cd "${ACTION_ROOT}" && pwd)"/*) ;; + *) + echo "error: engine outside ACTION_PATH" >&2 + return 1 + ;; + esac + printf '%s\n' "$ENGINE" +} + +RESOLVED="$(resolve_engine "$WORKDIR/action-path")" || fail "action-path resolve failed" +case "$RESOLVED" in + *hostile*) fail "resolved engine under hostile workspace: $RESOLVED" ;; +esac +# Confirm hostile would not be chosen even if WORKSPACE were preferred by old bug +[ -f "$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" ] || fail "hostile fixture missing" +# If we only resolve under action path, running it must not print HOSTILE +OUT_RES="$WORKDIR/resolve-out.txt" +# Don't actually run full bootstrap — just confirm path identity +[ "$RESOLVED" = "$(cd "$WORKDIR/action-path" && pwd)/bootstrap-sfetch-verified.sh" ] || + fail "unexpected resolve path: $RESOLVED" +# Missing action engine must fail (no workspace fallback) +if resolve_engine "$WORKDIR/missing-action" 2>/dev/null; then + fail "missing action engine must fail" +fi +pass "action engine resolves only under GITHUB_ACTION_PATH (hostile workspace ignored)" + +# Cleanup servers +kill "$SRV_PID" 2>/dev/null || true +kill "$SRV_PID2" 2>/dev/null || true +wait "$SRV_PID" 2>/dev/null || true +wait "$SRV_PID2" 2>/dev/null || true + +# Negative: requested goneat with impossible tag fails closed (no soft skip). +# Uses live N-1 sfetch install then fails on goneat — only when network allowed. +if [ "${SFETCH_BOOTSTRAP_LIVE:-0}" = "1" ] || [ "${GITHUB_ACTIONS:-}" = "true" ]; then + GONEAT_FAIL_DIR="$WORKDIR/goneat-fail" + mkdir -p "$GONEAT_FAIL_DIR" + set +e + OUT_GF="$WORKDIR/out-goneat-fail.txt" + "$SCRIPT" --version v0.4.10 --dir "$GONEAT_FAIL_DIR" --goneat-version v0.0.0 >"$OUT_GF" 2>&1 + RC=$? + set -e + [ "$RC" -ne 0 ] || fail "requested unavailable goneat must fail closed" + pass "requested goneat v0.0.0 fails closed" +else + # Offline unit: install stub sfetch then invoke goneat path by re-using stub dir + # with a fake sfetch that fails on goneat fetch — covered by engine's || die path. + # Explicit offline simulation: engine with BASE_URL fixture installs sfetch stub, + # then fails when goneat install is requested (sfetch binary is a stub that exits 1). + make_stub_installer_fail_goneat() { + local dest="$1" + cat >"$dest" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +DIR="" +TAG="" +while [ $# -gt 0 ]; do + case "$1" in + --dir) DIR="$2"; shift 2 ;; + --tag) TAG="$2"; shift 2 ;; + --yes|--require-minisign) shift ;; + *) shift ;; + esac +done +mkdir -p "${DIR}" +VER="${TAG#v}" +cat >"${DIR}/sfetch" <&2 +exit 1 +EOF +chmod +x "${DIR}/sfetch" +STUB + chmod +x "$dest" + } + GONEROOT="$WORKDIR/www-goneat" + mkdir -p "$GONEROOT/v0.4.11" + make_stub_installer_fail_goneat "$GONEROOT/v0.4.11/install-sfetch.sh" + minisign -S -s "$KEY" -t "g" -m "$GONEROOT/v0.4.11/install-sfetch.sh" + PORTFILE3="$WORKDIR/port3" + start_http_fixture "$GONEROOT" "$PORTFILE3" + SRV_PID3=$! + for _ in $(seq 1 50); do + [ -f "$PORTFILE3" ] && break + sleep 0.05 + done + [ -f "$PORTFILE3" ] || fail "goneat HTTP fixture server failed to start" + PORT3="$(cat "$PORTFILE3")" + GONEAT_DIR="$WORKDIR/goneat-offline" + mkdir -p "$GONEAT_DIR" + set +e + OUT_GO="$WORKDIR/out-goneat-offline.txt" + SFETCH_BOOTSTRAP_BASE_URL="http://127.0.0.1:${PORT3}" \ + SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ + "$PATCHED" --version v0.4.11 --dir "$GONEAT_DIR" --goneat-version v0.5.15 >"$OUT_GO" 2>&1 + RC=$? + set -e + kill "$SRV_PID3" 2>/dev/null || true + wait "$SRV_PID3" 2>/dev/null || true + [ "$RC" -ne 0 ] || fail "goneat install failure must fail closed (log: $(cat "$OUT_GO"))" + pass "requested goneat fails closed when sfetch install of goneat fails" +fi + +# Live install against published v0.4.10 when explicitly enabled or in GHA. +if [ "${SFETCH_BOOTSTRAP_LIVE:-0}" = "1" ] || [ "${GITHUB_ACTIONS:-}" = "true" ]; then + LIVE_DIR="$WORKDIR/live" + mkdir -p "$LIVE_DIR" + if "$SCRIPT" --version v0.4.10 --dir "$LIVE_DIR"; then + "$VERSION_MATCH" "$("$LIVE_DIR/sfetch" --version 2>&1)" "v0.4.10" || fail "live v0.4.10 version" + pass "live v0.4.10 sha256sums route install" + else + fail "live v0.4.10 install failed" + fi +else + pass "skip live install (set SFETCH_BOOTSTRAP_LIVE=1 or GITHUB_ACTIONS=true to enable)" +fi + echo "[ok] bootstrap-sfetch-verified regression harness complete" diff --git a/scripts/test-release-verify-signatures.sh b/scripts/test-release-verify-signatures.sh index fbb85c6..cc485d8 100755 --- a/scripts/test-release-verify-signatures.sh +++ b/scripts/test-release-verify-signatures.sh @@ -131,4 +131,45 @@ fi grep -q 'install-sfetch.sh' "$GEN/SHA256SUMS" || fail "install-sfetch.sh should still be checksummed" pass "installer remains in checksums; minisig skipped" +# Case 8: PGP-on-manifests-only when PGP is enabled (ephemeral key; no installer .asc) +if command -v gpg >/dev/null 2>&1; then + GPG_HOME="${WORKDIR}/gnupg" + mkdir -p "$GPG_HOME" + chmod 700 "$GPG_HOME" + # Batch-generate an ephemeral RSA key (no passphrase) + cat >"${WORKDIR}/gpg-batch" </dev/null 2>&1 || + fail "ephemeral gpg keygen failed" + PGP_ID="$(gpg --homedir "$GPG_HOME" --list-keys --with-colons 2>/dev/null | awk -F: '/^pub/{print $5; exit}')" + [ -n "$PGP_ID" ] || fail "could not read ephemeral PGP key id" + + PGP_DIR="${WORKDIR}/pgp" + mkdir -p "$PGP_DIR" + cp "$STAGE/install-sfetch.sh" "$PGP_DIR/" + cp "$STAGE/SHA256SUMS" "$PGP_DIR/" + cp "$STAGE/SHA512SUMS" "$PGP_DIR/" + SFETCH_MINISIGN_KEY="$KEY" \ + SFETCH_PGP_KEY_ID="$PGP_ID" \ + SFETCH_GPG_HOMEDIR="$GPG_HOME" \ + ./scripts/sign-release-manifests.sh v0.0.0-pgp-test "$PGP_DIR" + + [ -f "$PGP_DIR/SHA256SUMS.minisig" ] || fail "PGP path still needs SHA256SUMS.minisig" + [ -f "$PGP_DIR/SHA512SUMS.minisig" ] || fail "PGP path still needs SHA512SUMS.minisig" + [ -f "$PGP_DIR/install-sfetch.sh.minisig" ] || fail "PGP path still needs install-sfetch.sh.minisig" + [ -f "$PGP_DIR/SHA256SUMS.asc" ] || fail "PGP should sign SHA256SUMS" + [ -f "$PGP_DIR/SHA512SUMS.asc" ] || fail "PGP should sign SHA512SUMS" + [ ! -f "$PGP_DIR/install-sfetch.sh.asc" ] || fail "PGP must not sign install-sfetch.sh" + pass "PGP signs manifests only; installer minisign-only (no .asc)" +else + pass "skip PGP target-set proof (gpg not available)" +fi + echo "[ok] release signature regression harness complete" diff --git a/scripts/version-matches-pin.sh b/scripts/version-matches-pin.sh new file mode 100755 index 0000000..b64d40f --- /dev/null +++ b/scripts/version-matches-pin.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# version-matches-pin.sh — exact version token match for tool --version output. +# +# Usage: version-matches-pin.sh +# pin-tag: vMAJOR.MINOR.PATCH (leading v optional in output) +# Exit 0 if output contains an exact version token equal to the pin. +# Exit 1 otherwise. +# +# Rejects substring/regex soft matches (e.g. "10x4y110" must not match v0.4.11; +# "10.4.11" must not match v0.4.11). +set -euo pipefail + +out="${1-}" +pin="${2-}" + +if [ -z "$pin" ]; then + echo "usage: version-matches-pin.sh " >&2 + exit 2 +fi + +ver="${pin#v}" +case "$ver" in + [0-9]*.[0-9]*.[0-9]*) + if ! [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: pin must be exact vMAJOR.MINOR.PATCH (got: $pin)" >&2 + exit 2 + fi + ;; + *) + echo "error: pin must be exact vMAJOR.MINOR.PATCH (got: $pin)" >&2 + exit 2 + ;; +esac + +# Escape dots for fixed-token regex; require non-digit boundaries. +esc="$(printf '%s' "$ver" | sed 's/\./\\./g')" +printf '%s\n' "$out" | grep -Eq "(^|[^0-9])v?${esc}([^0-9]|$)" From ecaffc988ca176e030d376af2495a6993d8e146e Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 12:26:12 -0400 Subject: [PATCH 03/14] chore: drop unused variable in bootstrap harness Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- scripts/test-bootstrap-sfetch-verified.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index 6f5a531..686bd82 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -287,8 +287,6 @@ case "$RESOLVED" in esac # Confirm hostile would not be chosen even if WORKSPACE were preferred by old bug [ -f "$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" ] || fail "hostile fixture missing" -# If we only resolve under action path, running it must not print HOSTILE -OUT_RES="$WORKDIR/resolve-out.txt" # Don't actually run full bootstrap — just confirm path identity [ "$RESOLVED" = "$(cd "$WORKDIR/action-path" && pwd)/bootstrap-sfetch-verified.sh" ] || fail "unexpected resolve path: $RESOLVED" From 9c051c1a7bb2d90abbf45de4b2b2af02d00cb667 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 12:34:09 -0400 Subject: [PATCH 04/14] fix: single engine SSOT, machine route field, exact tokens Emit one stdout route= field for the action wrapper; human logs use verify-route=. Resolve the shared engine from the action repository root scripts/ path only (remove colocated copy). Acquire-minisign is a thin wrapper over --acquire-minisign-only. Match versions by exact whitespace tokens (reject -rc1 suffixes). Drop production || true on minisign -v. Expand harness for action parse simulation and negatives. Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- .github/actions/setup-sfetch/action.yml | 95 ++-- .../setup-sfetch/bootstrap-sfetch-verified.sh | 527 ------------------ scripts/acquire-minisign-pinned.sh | 216 +------ scripts/bootstrap-sfetch-verified.sh | 155 ++++-- scripts/test-bootstrap-sfetch-verified.sh | 113 ++-- scripts/version-matches-pin.sh | 56 +- 6 files changed, 277 insertions(+), 885 deletions(-) delete mode 100755 .github/actions/setup-sfetch/bootstrap-sfetch-verified.sh diff --git a/.github/actions/setup-sfetch/action.yml b/.github/actions/setup-sfetch/action.yml index 7a31fea..5c261f1 100644 --- a/.github/actions/setup-sfetch/action.yml +++ b/.github/actions/setup-sfetch/action.yml @@ -9,21 +9,23 @@ # Trust model: # Consumers pin this action by commit SHA (not a moving tag). That SHA is the # TCB for the verification engine — same model as actions/checkout. -# The engine script is colocated under GITHUB_ACTION_PATH (this directory). -# Never resolved from GITHUB_WORKSPACE (consumer repo contents cannot replace it). -# The sfetch trust anchor is embedded in the engine script; never fetched from +# GitHub checks out the whole action repository for a nested composite action. +# The engine is the single canonical script at +# /scripts/bootstrap-sfetch-verified.sh (resolved from +# GITHUB_ACTION_PATH/../../../scripts/...). Never from GITHUB_WORKSPACE +# (consumer repo). Trust anchor is embedded in the engine; never fetched from # the release being authenticated. # -# Dual-route (selected by sfetch-version; logged; no silent fallback): +# Dual-route (selected by sfetch-version; machine field route= on stdout): # >= v0.4.11 → install-sfetch.sh.minisig # < v0.4.11 → signed SHA256SUMS + installer hash # # Fail-closed: no continue-on-error, no || true soft skips, no @latest tools, # no Go toolchain requirement, no ambient minisign preference, no workspace -# engine fallback. +# engine fallback. One shared engine — this action is a thin wrapper. # # Supported sfetch-version range is declared by the engine revision shipped with -# this action SHA (see bootstrap-sfetch-verified.sh constants). +# this action SHA (see scripts/bootstrap-sfetch-verified.sh constants). name: "Setup sfetch" description: "Install a pinned, minisign-verified sfetch (optional goneat) without pipe-to-bash" author: "3leaps" @@ -68,28 +70,40 @@ runs: run: | set -euo pipefail - # Resolve engine only beneath GITHUB_ACTION_PATH (action-owned TCB). - # Never fall back to GITHUB_WORKSPACE — consumer repo contents must not - # replace the pinned action's verification engine. - ACTION_ROOT="${GITHUB_ACTION_PATH}" - ENGINE="${ACTION_ROOT}/bootstrap-sfetch-verified.sh" + # Resolve single canonical engine inside the action repository checkout. + # Nested composite actions live at .github/actions//; the package + # root is three levels up. Never consult GITHUB_WORKSPACE (consumer). + ACTION_PATH_REAL="$(cd "${GITHUB_ACTION_PATH}" && pwd)" + PACKAGE_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" + ENGINE="${PACKAGE_ROOT}/scripts/bootstrap-sfetch-verified.sh" if [ ! -f "${ENGINE}" ] || [ ! -r "${ENGINE}" ]; then - echo "error: action-owned engine missing or unreadable: ${ENGINE}" >&2 + echo "error: action-repo engine missing or unreadable: ${ENGINE}" >&2 exit 1 fi - # Require a regular file (reject unexpected directory / special nodes). - if [ ! -f "${ENGINE}" ] || [ -d "${ENGINE}" ]; then - echo "error: action-owned engine is not a regular file: ${ENGINE}" >&2 + if [ -d "${ENGINE}" ]; then + echo "error: engine path is a directory: ${ENGINE}" >&2 exit 1 fi ENGINE="$(cd "$(dirname "${ENGINE}")" && pwd)/$(basename "${ENGINE}")" case "${ENGINE}" in - "${ACTION_ROOT}"/* | "$(cd "${ACTION_ROOT}" && pwd)"/*) ;; + "${PACKAGE_ROOT}"/*) ;; *) - echo "error: engine resolved outside GITHUB_ACTION_PATH: ${ENGINE}" >&2 + echo "error: engine resolved outside action repository checkout: ${ENGINE}" >&2 exit 1 ;; esac + # If workspace is a different tree, refuse to have resolved into it. + if [ -n "${GITHUB_WORKSPACE:-}" ]; then + WS_REAL="$(cd "${GITHUB_WORKSPACE}" && pwd)" + if [ "${PACKAGE_ROOT}" != "${WS_REAL}" ]; then + case "${ENGINE}" in + "${WS_REAL}"/*) + echo "error: engine resolved into consumer GITHUB_WORKSPACE" >&2 + exit 1 + ;; + esac + fi + fi chmod +x "${ENGINE}" # Scrub test-only override variables so production action path cannot be @@ -126,7 +140,7 @@ runs: ARGS+=(--goneat-version "${INPUT_GONEAT_VERSION}") fi - # Capture route from stderr log while still failing closed on non-zero. + # Capture machine stdout separately from human stderr logs. LOG="$(mktemp)" set +e OUT="$("${ENGINE}" "${ARGS[@]}" 2>"${LOG}")" @@ -138,50 +152,51 @@ runs: rm -f "${LOG}" exit "${RC}" fi + rm -f "${LOG}" - # Require exactly one valid terminal route line (fail closed; no soft empty). - set +e - ROUTE_LINES="$(grep -E 'route=(minisig|sha256sums)([[:space:]]|$)' "${LOG}")" - GREP_RC=$? - set -e - if [ "${GREP_RC}" -ne 0 ] || [ -z "${ROUTE_LINES}" ]; then - echo "error: no route=(minisig|sha256sums) log line from engine" >&2 - rm -f "${LOG}" - exit 1 - fi - ROUTE_COUNT="$(printf '%s\n' "${ROUTE_LINES}" | grep -c .)" + # Machine-readable fields only: exactly one ^route= line on stdout. + ROUTE_COUNT="$(printf '%s\n' "${OUT}" | grep -c '^route=' || true)" if [ "${ROUTE_COUNT}" -ne 1 ]; then - echo "error: expected exactly one route=(minisig|sha256sums) log line, got ${ROUTE_COUNT}" >&2 - rm -f "${LOG}" + echo "error: expected exactly one stdout route= field, got ${ROUTE_COUNT}" >&2 exit 1 fi - ROUTE="$(printf '%s\n' "${ROUTE_LINES}" | sed -E 's/.*route=(minisig|sha256sums).*/\1/')" + ROUTE="$(printf '%s\n' "${OUT}" | awk -F= '/^route=/{print $2; exit}')" case "${ROUTE}" in minisig|sha256sums) ;; *) echo "error: invalid route value: ${ROUTE}" >&2 - rm -f "${LOG}" exit 1 ;; esac SFETCH_BIN="$(printf '%s\n' "${OUT}" | awk -F= '/^sfetch-bin=/{print $2; exit}')" GONEAT_BIN="$(printf '%s\n' "${OUT}" | awk -F= '/^goneat-bin=/{print $2; exit}')" - rm -f "${LOG}" if [ -z "${SFETCH_BIN}" ] || [ ! -f "${SFETCH_BIN}" ]; then echo "error: sfetch binary path missing after bootstrap" >&2 exit 1 fi - # Exact version token check on installed binary (reuse engine rules). + # Exact whitespace-token version match (shared rules with engine). + version_matches_pin() { + local out="$1" pin="$2" want="${2#v}" tok + while IFS= read -r tok; do + [ -n "$tok" ] || continue + case "$tok" in + v*) [ "${tok#v}" = "$want" ] && return 0 ;; + esac + [ "$tok" = "$want" ] && return 0 + done <&1)" || { echo "error: sfetch --version failed after bootstrap" >&2 exit 1 } - VER_NUM="${VERSION#v}" - ESC="$(printf '%s' "${VER_NUM}" | sed 's/\./\\./g')" - if ! printf '%s\n' "${REPORT}" | grep -Eq "(^|[^0-9])v?${ESC}([^0-9]|$)"; then + if ! version_matches_pin "${REPORT}" "${VERSION}"; then echo "error: sfetch version assertion failed: expected ${VERSION}, got: ${REPORT}" >&2 exit 1 fi @@ -195,9 +210,7 @@ runs: echo "error: goneat version command failed" >&2 exit 1 } - GNUM="${INPUT_GONEAT_VERSION#v}" - GESC="$(printf '%s' "${GNUM}" | sed 's/\./\\./g')" - if ! printf '%s\n' "${GREP}" | grep -Eq "(^|[^0-9])v?${GESC}([^0-9]|$)"; then + if ! version_matches_pin "${GREP}" "${INPUT_GONEAT_VERSION}"; then echo "error: goneat version assertion failed: expected ${INPUT_GONEAT_VERSION}, got: ${GREP}" >&2 exit 1 fi diff --git a/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh b/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh deleted file mode 100755 index 999c8e8..0000000 --- a/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh +++ /dev/null @@ -1,527 +0,0 @@ -#!/usr/bin/env bash -# bootstrap-sfetch-verified.sh — verified sfetch install for Makefile / CI -# -# Fail-closed dual-route bootstrap: -# - v0.4.11+ → detached install-sfetch.sh.minisig (3-step) -# - earlier → SHA256SUMS + SHA256SUMS.minisig + installer hash (5-step) -# -# Never falls back from a failed .minisig attempt to checksums. -# Never accepts "latest", branches, or floating refs. -# Trust anchor is embedded here — never fetched from the release being authenticated. -# -# Usage: -# bootstrap-sfetch-verified.sh --version v0.4.11 --dir ~/.local/bin -# bootstrap-sfetch-verified.sh --version v0.4.10 --dir ./bin --goneat-version v0.5.15 -# -# Env (test fixtures only — never set in production/CI action path): -# SFETCH_BOOTSTRAP_BASE_URL Override GitHub download base -# (default: https://github.com/3leaps/sfetch/releases/download) -# SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL If set to 1, use ambient minisign -# already on PATH (unit fixtures only). Production always downloads the -# pinned official 0.12 archive and hash-verifies it. -# -# Makefile consumers outside this repo should retrieve this script at an -# immutable git SHA and verify a pinned SHA-256 of the script before executing -# it (see docs/cicd-usage-guide.md). The composite action trusts the script -# via the pinned action SHA instead (engine is colocated under the action path). -# -set -euo pipefail - -# ----------------------------------------------------------------------------- -# Contract constants (bump deliberately when expanding support) -# ----------------------------------------------------------------------------- -# Supported sfetch-version range for THIS script revision. Outside range ⇒ fail. -# Action SHA identifies this contract; consumers pin the action/script, then a -# version within the range it declares. -readonly SFETCH_BOOTSTRAP_MIN="v0.4.9" -readonly SFETCH_BOOTSTRAP_MAX="v0.4.11" -# First release that publishes install-sfetch.sh.minisig -readonly SFETCH_MINISIG_SINCE="v0.4.11" - -# Embedded trust anchor — must match EmbeddedMinisignPubkey in main.go and -# scripts/install-sfetch.sh. Do NOT fetch sfetch-minisign.pub from the release -# for authentication (circular: same origin as the artifact under test). -# The published .pub is for human out-of-band comparison only. -readonly SFETCH_MINISIGN_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" - -readonly MINISIGN_VERSION_EXPECTED="0.12" -readonly MINISIGN_WIN_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-win64.zip" -readonly MINISIGN_WIN_SHA256="37b600344e20c19314b2e82813db2bfdcc408b77b876f7727889dbd46d539479" -readonly MINISIGN_MAC_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip" -readonly MINISIGN_MAC_SHA256="89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63" -readonly MINISIGN_LINUX_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz" -readonly MINISIGN_LINUX_SHA256="9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73" - -readonly SFETCH_REPO_DEFAULT="3leaps/sfetch" - -# ----------------------------------------------------------------------------- -# Logging / errors -# ----------------------------------------------------------------------------- -log() { printf '%s\n' "$*" >&2; } -die() { - log "error: $*" - exit 1 -} - -# ----------------------------------------------------------------------------- -# Semver helpers (tags must be vMAJOR.MINOR.PATCH, optional -prerelease rejected) -# ----------------------------------------------------------------------------- -is_exact_semver_tag() { - case "$1" in - v[0-9]*.[0-9]*.[0-9]*) - # Reject extra suffix (pre-release / build) and non-numeric parts - [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] - ;; - *) return 1 ;; - esac -} - -# Compare two vX.Y.Z tags: echo -1 / 0 / 1 for ab -semver_cmp() { - local a="${1#v}" b="${2#v}" - local a1 a2 a3 b1 b2 b3 - IFS=. read -r a1 a2 a3 <<<"$a" - IFS=. read -r b1 b2 b3 <<<"$b" - if ((a1 != b1)); then - if ((a1 < b1)); then echo -1; else echo 1; fi - return - fi - if ((a2 != b2)); then - if ((a2 < b2)); then echo -1; else echo 1; fi - return - fi - if ((a3 != b3)); then - if ((a3 < b3)); then echo -1; else echo 1; fi - return - fi - echo 0 -} - -semver_ge() { [[ "$(semver_cmp "$1" "$2")" != "-1" ]]; } -semver_le() { [[ "$(semver_cmp "$1" "$2")" != "1" ]]; } - -# ----------------------------------------------------------------------------- -# Args -# ----------------------------------------------------------------------------- -VERSION="" -INSTALL_DIR="" -GONEAT_VERSION="" -REPO="${SFETCH_REPO_DEFAULT}" -usage() { - cat <<'EOF' >&2 -Usage: bootstrap-sfetch-verified.sh --version vX.Y.Z --dir PATH [options] - -Required: - --version TAG Exact immutable tag (e.g. v0.4.11). Rejects latest/branches. - --dir PATH Install directory for sfetch (and optional goneat) - -Optional: - --goneat-version TAG Exact goneat tag to install via verified sfetch - --repo owner/name GitHub repo (default: 3leaps/sfetch) - --yes Non-interactive (always on for this script; accepted for CLI parity) - -h, --help Show help - -Env: - SFETCH_BOOTSTRAP_BASE_URL Override download base (tests) -EOF - exit 2 -} - -while [ $# -gt 0 ]; do - case "$1" in - --version) - [ $# -ge 2 ] || die "--version requires an argument" - VERSION="$2" - shift 2 - ;; - --dir) - [ $# -ge 2 ] || die "--dir requires an argument" - INSTALL_DIR="$2" - shift 2 - ;; - --goneat-version) - [ $# -ge 2 ] || die "--goneat-version requires an argument" - GONEAT_VERSION="$2" - shift 2 - ;; - --repo) - [ $# -ge 2 ] || die "--repo requires an argument" - REPO="$2" - shift 2 - ;; - --yes) - # Accepted for CLI parity with install-sfetch.sh (always non-interactive). - shift - ;; - -h | --help) - usage - ;; - *) - die "unknown option: $1 (see --help)" - ;; - esac -done - -[ -n "$VERSION" ] || die "--version is required" -[ -n "$INSTALL_DIR" ] || die "--dir is required" - -# Reject floating / non-immutable refs (CI and Makefile must never use latest). -case "$VERSION" in - "" | latest | LATEST | main | master | HEAD) - die "refusing floating version ${VERSION:-}; pin an exact tag (e.g. v0.4.11)" - ;; -esac -if ! is_exact_semver_tag "$VERSION"; then - die "version must be exact vMAJOR.MINOR.PATCH (got: $VERSION)" -fi -if [ -n "$GONEAT_VERSION" ]; then - case "$GONEAT_VERSION" in - "" | latest | LATEST | main | master | HEAD) - die "refusing floating goneat-version: ${GONEAT_VERSION:-}" - ;; - esac - if ! is_exact_semver_tag "$GONEAT_VERSION"; then - die "goneat-version must be exact vMAJOR.MINOR.PATCH (got: $GONEAT_VERSION)" - fi -fi - -if ! semver_ge "$VERSION" "$SFETCH_BOOTSTRAP_MIN" || ! semver_le "$VERSION" "$SFETCH_BOOTSTRAP_MAX"; then - die "sfetch-version $VERSION outside supported range ${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX} for this bootstrap revision" -fi - -BASE_URL="${SFETCH_BOOTSTRAP_BASE_URL:-https://github.com/${REPO}/releases/download}" -ASSET_BASE="${BASE_URL}/${VERSION}" - -# Route selection (logged; no silent downgrade between routes) -ROUTE="" -if semver_ge "$VERSION" "$SFETCH_MINISIG_SINCE"; then - ROUTE="minisig" -else - ROUTE="sha256sums" -fi -log "bootstrap-sfetch-verified: version=${VERSION} route=${ROUTE} range=${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX}" - -# ----------------------------------------------------------------------------- -# Platform -# ----------------------------------------------------------------------------- -detect_os() { - case "$(uname -s 2>/dev/null || echo unknown)" in - Linux*) echo linux ;; - Darwin*) echo darwin ;; - MINGW* | MSYS* | CYGWIN* | Windows_NT) echo windows ;; - *) - # GitHub Actions Windows often reports MINGW via bash - if [ -n "${RUNNER_OS:-}" ]; then - case "${RUNNER_OS}" in - Windows) echo windows ;; - Linux) echo linux ;; - macOS) echo darwin ;; - *) die "unsupported OS: ${RUNNER_OS}" ;; - esac - else - die "unsupported OS: $(uname -s 2>/dev/null || echo unknown)" - fi - ;; - esac -} - -detect_arch() { - # Prefer GitHub runner arch when present (handles Windows ARM64 host quirks) - if [ -n "${RUNNER_ARCH:-}" ]; then - case "${RUNNER_ARCH}" in - X64 | x64 | AMD64 | amd64) - echo x86_64 - return - ;; - ARM64 | arm64) - echo aarch64 - return - ;; - *) die "unsupported RUNNER_ARCH: ${RUNNER_ARCH}" ;; - esac - fi - local m - m="$(uname -m 2>/dev/null || echo unknown)" - case "$m" in - x86_64 | amd64) echo x86_64 ;; - aarch64 | arm64) echo aarch64 ;; - *) die "unsupported architecture: $m" ;; - esac -} - -OS="$(detect_os)" -ARCH="$(detect_arch)" -log "platform: os=${OS} arch=${ARCH}" - -# macOS Intel: upstream 0.12 macOS archive is arm64-only — fail closed. -if [ "$OS" = "darwin" ] && [ "$ARCH" = "x86_64" ]; then - die "macOS x86_64 is not supported by the pinned minisign 0.12 macOS archive (arm64-only); use an arm64 runner" -fi - -# ----------------------------------------------------------------------------- -# Temp workspace (private; cleaned on exit) -# ----------------------------------------------------------------------------- -WORK="$(mktemp -d "${TMPDIR:-/tmp}/sfetch-bootstrap.XXXXXX")" -cleanup() { - rm -rf "${WORK}" -} -trap cleanup EXIT - -mkdir -p "$INSTALL_DIR" -INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" - -# ----------------------------------------------------------------------------- -# HTTPS fetch with bounded retries -# ----------------------------------------------------------------------------- -http_get() { - local url="$1" out="$2" - local attempt=1 max=4 - while [ "$attempt" -le "$max" ]; do - if command -v curl >/dev/null 2>&1; then - if curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 15 --max-time 120 \ - -o "$out" "$url"; then - return 0 - fi - elif command -v wget >/dev/null 2>&1; then - if wget -q -O "$out" "$url"; then - return 0 - fi - else - die "curl or wget is required" - fi - log "fetch attempt ${attempt}/${max} failed: $url" - attempt=$((attempt + 1)) - sleep 1 - done - die "failed to fetch after ${max} attempts: $url" -} - -sha256_file() { - local f="$1" - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$f" | awk '{print $1}' - elif command -v sha256sum >/dev/null 2>&1; then - sha256sum "$f" | awk '{print $1}' - else - die "shasum or sha256sum required" - fi -} - -assert_sha256() { - local f="$1" expect="$2" - local got - got="$(sha256_file "$f")" - if [ "$got" != "$expect" ]; then - die "SHA-256 mismatch for $(basename "$f"): expected ${expect}, got ${got}" - fi -} - -# ----------------------------------------------------------------------------- -# Minisign acquisition (pinned official archive only; never prefer ambient PATH) -# ----------------------------------------------------------------------------- -MINISIGN_BIN="" - -# Exact version token match (same rules as scripts/version-matches-pin.sh). -version_output_matches_pin() { - local out="$1" pin="$2" - local ver="${pin#v}" - local esc - esc="$(printf '%s' "$ver" | sed 's/\./\\./g')" - printf '%s\n' "$out" | grep -Eq "(^|[^0-9])v?${esc}([^0-9]|$)" -} - -ensure_minisign() { - # Test-only seam: ambient minisign with exact version identity. - # Production and the composite action never set this (action scrubs it). - if [ "${SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL:-0}" = "1" ]; then - command -v minisign >/dev/null 2>&1 || - die "minisign required on PATH when SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1" - MINISIGN_BIN="$(command -v minisign)" - assert_minisign_version - log "using ambient minisign (test seam SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1): ${MINISIGN_BIN}" - return 0 - fi - - # Always download + hash-verify the pinned upstream archive. - # Do not prefer ambient PATH minisign (PATH shims must not become the verifier). - local tools="${WORK}/tools" - mkdir -p "$tools" - case "$OS" in - windows) - local zip="${WORK}/minisign-win.zip" - http_get "$MINISIGN_WIN_URL" "$zip" - assert_sha256 "$zip" "$MINISIGN_WIN_SHA256" - if command -v unzip >/dev/null 2>&1; then - unzip -q -o "$zip" -d "${WORK}/minisign-extract" - else - # PowerShell Expand-Archive on Windows runners - powershell.exe -NoProfile -Command \ - "Expand-Archive -LiteralPath '$zip' -DestinationPath '${WORK}/minisign-extract' -Force" || - die "failed to extract minisign zip" - fi - local sub - case "$ARCH" in - x86_64) sub="x86_64" ;; - aarch64) sub="aarch64" ;; - *) die "unsupported Windows arch: $ARCH" ;; - esac - local src="${WORK}/minisign-extract/minisign-win64/${sub}/minisign.exe" - [ -f "$src" ] || die "minisign.exe not found at $src" - cp "$src" "${tools}/minisign.exe" - MINISIGN_BIN="${tools}/minisign.exe" - ;; - darwin) - local zip="${WORK}/minisign-mac.zip" - http_get "$MINISIGN_MAC_URL" "$zip" - assert_sha256 "$zip" "$MINISIGN_MAC_SHA256" - unzip -q -o "$zip" -d "${WORK}/minisign-extract" - [ -f "${WORK}/minisign-extract/minisign" ] || die "minisign binary missing from macOS archive" - cp "${WORK}/minisign-extract/minisign" "${tools}/minisign" - chmod 0755 "${tools}/minisign" - MINISIGN_BIN="${tools}/minisign" - ;; - linux) - local tgz="${WORK}/minisign-linux.tar.gz" - http_get "$MINISIGN_LINUX_URL" "$tgz" - assert_sha256 "$tgz" "$MINISIGN_LINUX_SHA256" - mkdir -p "${WORK}/minisign-extract" - tar -xzf "$tgz" -C "${WORK}/minisign-extract" - local sub - case "$ARCH" in - x86_64) sub="x86_64" ;; - aarch64) sub="aarch64" ;; - *) die "unsupported Linux arch: $ARCH" ;; - esac - local src="${WORK}/minisign-extract/minisign-linux/${sub}/minisign" - [ -f "$src" ] || die "minisign not found at $src" - cp "$src" "${tools}/minisign" - chmod 0755 "${tools}/minisign" - MINISIGN_BIN="${tools}/minisign" - ;; - *) - die "unsupported OS for minisign install: $OS" - ;; - esac - assert_minisign_version - log "minisign ready (pinned archive): ${MINISIGN_BIN}" -} - -assert_minisign_version() { - local out - out="$("$MINISIGN_BIN" -v 2>&1 | head -n1 || true)" - version_output_matches_pin "$out" "$MINISIGN_VERSION_EXPECTED" || - die "minisign version assertion failed (want exact ${MINISIGN_VERSION_EXPECTED}): ${out}" -} - -write_pubkey() { - local path="$1" - # minisign -p expects a file with optional comment + RW... key line - { - echo "untrusted comment: sfetch release signing key (embedded trust anchor)" - echo "${SFETCH_MINISIGN_PUBKEY}" - } >"$path" -} - -# ----------------------------------------------------------------------------- -# Verification routes -# ----------------------------------------------------------------------------- -verify_installer_minisig() { - local script="$1" - local _sig="$2" - local pub="$3" - log "verify route=minisig: minisign -Vm install-sfetch.sh" - if ! "$MINISIGN_BIN" -Vm "$script" -p "$pub" -x "$_sig" >/dev/null; then - die "install-sfetch.sh.minisig verification failed (route=minisig; no fallback)" - fi - log "install-sfetch.sh.minisig: OK" -} - -verify_installer_sha256sums() { - local script="$1" sums="$2" sums_sig="$3" pub="$4" - log "verify route=sha256sums: minisign -Vm SHA256SUMS then hash install-sfetch.sh" - if ! "$MINISIGN_BIN" -Vm "$sums" -p "$pub" -x "$sums_sig" >/dev/null; then - die "SHA256SUMS.minisig verification failed (route=sha256sums; no fallback)" - fi - local expect got - expect="$(awk '$2 == "install-sfetch.sh" { print $1; exit }' "$sums")" - [ -n "$expect" ] || die "install-sfetch.sh not listed in SHA256SUMS" - got="$(sha256_file "$script")" - if [ "$got" != "$expect" ]; then - die "install-sfetch.sh SHA-256 mismatch: expected ${expect}, got ${got}" - fi - log "install-sfetch.sh SHA-256 via signed SHA256SUMS: OK" -} - -# ----------------------------------------------------------------------------- -# Install sfetch -# ----------------------------------------------------------------------------- -ensure_minisign - -PUB="${WORK}/sfetch-minisign.pub" -write_pubkey "$PUB" - -SCRIPT="${WORK}/install-sfetch.sh" -http_get "${ASSET_BASE}/install-sfetch.sh" "$SCRIPT" -chmod 0755 "$SCRIPT" - -case "$ROUTE" in - minisig) - SIG="${WORK}/install-sfetch.sh.minisig" - http_get "${ASSET_BASE}/install-sfetch.sh.minisig" "$SIG" - verify_installer_minisig "$SCRIPT" "$SIG" "$PUB" - ;; - sha256sums) - SUMS="${WORK}/SHA256SUMS" - SUMS_SIG="${WORK}/SHA256SUMS.minisig" - http_get "${ASSET_BASE}/SHA256SUMS" "$SUMS" - http_get "${ASSET_BASE}/SHA256SUMS.minisig" "$SUMS_SIG" - verify_installer_sha256sums "$SCRIPT" "$SUMS" "$SUMS_SIG" "$PUB" - ;; - *) - die "internal error: unknown route $ROUTE" - ;; -esac - -# Execute only after verification (never pipe curl | bash) -log "executing verified installer for ${VERSION} → ${INSTALL_DIR}" -# shellcheck disable=SC2086 -bash "$SCRIPT" --tag "$VERSION" --dir "$INSTALL_DIR" --yes --require-minisign - -SFETCH_BIN="${INSTALL_DIR}/sfetch" -if [ "$OS" = "windows" ]; then - if [ -f "${INSTALL_DIR}/sfetch.exe" ]; then - SFETCH_BIN="${INSTALL_DIR}/sfetch.exe" - fi -fi -[ -x "$SFETCH_BIN" ] || [ -f "$SFETCH_BIN" ] || die "sfetch binary missing after install: $SFETCH_BIN" - -# Exact version assertion (token boundary; not soft substring/regex) -REPORT="$("$SFETCH_BIN" --version 2>&1)" || die "sfetch --version failed after install" -log "sfetch reports: ${REPORT}" -version_output_matches_pin "$REPORT" "$VERSION" || - die "sfetch version assertion failed: expected ${VERSION}, got: ${REPORT}" - -# Optional goneat via verified sfetch (no Go toolchain). Fail closed: any -# install or version failure is non-zero (no soft skip when requested). -if [ -n "$GONEAT_VERSION" ]; then - log "installing goneat ${GONEAT_VERSION} via verified sfetch" - "$SFETCH_BIN" --repo fulmenhq/goneat --tag "$GONEAT_VERSION" \ - --dest-dir "$INSTALL_DIR" --require-minisign || - die "goneat install failed for ${GONEAT_VERSION} (requested; fail closed)" - GONEAT_BIN="${INSTALL_DIR}/goneat" - if [ "$OS" = "windows" ] && [ -f "${INSTALL_DIR}/goneat.exe" ]; then - GONEAT_BIN="${INSTALL_DIR}/goneat.exe" - fi - [ -f "$GONEAT_BIN" ] || die "goneat binary missing after install" - GREP="$("$GONEAT_BIN" version 2>&1 | head -n1)" || die "goneat version command failed" - version_output_matches_pin "$GREP" "$GONEAT_VERSION" || - die "goneat version assertion failed: expected ${GONEAT_VERSION}, got: ${GREP}" - log "goneat OK: ${GREP}" -fi - -log "bootstrap-sfetch-verified complete: sfetch=${VERSION} route=${ROUTE} dir=${INSTALL_DIR}" -# Emit install path for action consumers (stdout only machine line) -printf 'sfetch-bin=%s\n' "$SFETCH_BIN" -if [ -n "$GONEAT_VERSION" ]; then - printf 'goneat-bin=%s\n' "$GONEAT_BIN" -fi diff --git a/scripts/acquire-minisign-pinned.sh b/scripts/acquire-minisign-pinned.sh index a8a8f0f..d852dec 100755 --- a/scripts/acquire-minisign-pinned.sh +++ b/scripts/acquire-minisign-pinned.sh @@ -1,212 +1,20 @@ #!/usr/bin/env bash -# acquire-minisign-pinned.sh — install official minisign 0.12 with hash verification. +# acquire-minisign-pinned.sh — thin wrapper over the shared bootstrap engine. # # Usage: acquire-minisign-pinned.sh --dir PATH -# Installs minisign (or minisign.exe on Windows) into PATH directory. -# Always downloads the pinned upstream archive; never prefers ambient PATH tools. -# -# Constants must stay aligned with scripts/bootstrap-sfetch-verified.sh. +# Installs official minisign 0.12 (hash-verified) into PATH. +# Single authoritative acquisition path: bootstrap-sfetch-verified.sh +# --acquire-minisign-only (constants + download live only there). set -euo pipefail -readonly MINISIGN_VERSION_EXPECTED="0.12" -readonly MINISIGN_WIN_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-win64.zip" -readonly MINISIGN_WIN_SHA256="37b600344e20c19314b2e82813db2bfdcc408b77b876f7727889dbd46d539479" -readonly MINISIGN_MAC_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip" -readonly MINISIGN_MAC_SHA256="89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63" -readonly MINISIGN_LINUX_URL="https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz" -readonly MINISIGN_LINUX_SHA256="9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73" - -DIR="" -while [ $# -gt 0 ]; do - case "$1" in - --dir) - [ $# -ge 2 ] || { - echo "error: --dir requires an argument" >&2 - exit 1 - } - DIR="$2" - shift 2 - ;; - -h | --help) - echo "Usage: acquire-minisign-pinned.sh --dir PATH" >&2 - exit 0 - ;; - *) - echo "error: unknown option: $1" >&2 - exit 1 - ;; - esac -done - -[ -n "$DIR" ] || { - echo "error: --dir is required" >&2 - exit 1 -} -mkdir -p "$DIR" -DIR="$(cd "$DIR" && pwd)" - -die() { - echo "error: $*" >&2 - exit 1 -} -log() { printf '%s\n' "$*" >&2; } - -detect_os() { - case "$(uname -s 2>/dev/null || echo unknown)" in - Linux*) echo linux ;; - Darwin*) echo darwin ;; - MINGW* | MSYS* | CYGWIN* | Windows_NT) echo windows ;; - *) - if [ -n "${RUNNER_OS:-}" ]; then - case "${RUNNER_OS}" in - Windows) echo windows ;; - Linux) echo linux ;; - macOS) echo darwin ;; - *) die "unsupported OS: ${RUNNER_OS}" ;; - esac - else - die "unsupported OS: $(uname -s 2>/dev/null || echo unknown)" - fi - ;; - esac -} - -detect_arch() { - if [ -n "${RUNNER_ARCH:-}" ]; then - case "${RUNNER_ARCH}" in - X64 | x64 | AMD64 | amd64) - echo x86_64 - return - ;; - ARM64 | arm64) - echo aarch64 - return - ;; - *) die "unsupported RUNNER_ARCH: ${RUNNER_ARCH}" ;; - esac - fi - local m - m="$(uname -m 2>/dev/null || echo unknown)" - case "$m" in - x86_64 | amd64) echo x86_64 ;; - aarch64 | arm64) echo aarch64 ;; - *) die "unsupported architecture: $m" ;; - esac -} - -http_get() { - local url="$1" out="$2" - local attempt=1 max=4 - while [ "$attempt" -le "$max" ]; do - if command -v curl >/dev/null 2>&1; then - if curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 15 --max-time 120 \ - -o "$out" "$url"; then - return 0 - fi - elif command -v wget >/dev/null 2>&1; then - if wget -q -O "$out" "$url"; then - return 0 - fi - else - die "curl or wget is required" - fi - log "fetch attempt ${attempt}/${max} failed: $url" - attempt=$((attempt + 1)) - sleep 1 - done - die "failed to fetch after ${max} attempts: $url" -} - -sha256_file() { - local f="$1" - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$f" | awk '{print $1}' - elif command -v sha256sum >/dev/null 2>&1; then - sha256sum "$f" | awk '{print $1}' +ROOT="$(cd "$(dirname "$0")" && pwd)" +ENGINE="${ROOT}/bootstrap-sfetch-verified.sh" +if [ ! -f "$ENGINE" ] || [ ! -x "$ENGINE" ]; then + if [ -f "$ENGINE" ]; then + chmod +x "$ENGINE" else - die "shasum or sha256sum required" + echo "error: shared engine missing: $ENGINE" >&2 + exit 1 fi -} - -assert_sha256() { - local f="$1" expect="$2" - local got - got="$(sha256_file "$f")" - if [ "$got" != "$expect" ]; then - die "SHA-256 mismatch for $(basename "$f"): expected ${expect}, got ${got}" - fi -} - -OS="$(detect_os)" -ARCH="$(detect_arch)" - -if [ "$OS" = "darwin" ] && [ "$ARCH" = "x86_64" ]; then - die "macOS x86_64 is not supported by the pinned minisign 0.12 macOS archive (arm64-only)" fi - -WORK="$(mktemp -d "${TMPDIR:-/tmp}/minisign-acquire.XXXXXX")" -cleanup() { rm -rf "${WORK}"; } -trap cleanup EXIT - -case "$OS" in - windows) - zip="${WORK}/minisign-win.zip" - http_get "$MINISIGN_WIN_URL" "$zip" - assert_sha256 "$zip" "$MINISIGN_WIN_SHA256" - if command -v unzip >/dev/null 2>&1; then - unzip -q -o "$zip" -d "${WORK}/extract" - else - powershell.exe -NoProfile -Command \ - "Expand-Archive -LiteralPath '$zip' -DestinationPath '${WORK}/extract' -Force" || - die "failed to extract minisign zip" - fi - case "$ARCH" in - x86_64) sub="x86_64" ;; - aarch64) sub="aarch64" ;; - *) die "unsupported Windows arch: $ARCH" ;; - esac - src="${WORK}/extract/minisign-win64/${sub}/minisign.exe" - [ -f "$src" ] || die "minisign.exe not found at $src" - cp "$src" "${DIR}/minisign.exe" - BIN="${DIR}/minisign.exe" - ;; - darwin) - zip="${WORK}/minisign-mac.zip" - http_get "$MINISIGN_MAC_URL" "$zip" - assert_sha256 "$zip" "$MINISIGN_MAC_SHA256" - unzip -q -o "$zip" -d "${WORK}/extract" - [ -f "${WORK}/extract/minisign" ] || die "minisign binary missing from macOS archive" - cp "${WORK}/extract/minisign" "${DIR}/minisign" - chmod 0755 "${DIR}/minisign" - BIN="${DIR}/minisign" - ;; - linux) - tgz="${WORK}/minisign-linux.tar.gz" - http_get "$MINISIGN_LINUX_URL" "$tgz" - assert_sha256 "$tgz" "$MINISIGN_LINUX_SHA256" - mkdir -p "${WORK}/extract" - tar -xzf "$tgz" -C "${WORK}/extract" - case "$ARCH" in - x86_64) sub="x86_64" ;; - aarch64) sub="aarch64" ;; - *) die "unsupported Linux arch: $ARCH" ;; - esac - src="${WORK}/extract/minisign-linux/${sub}/minisign" - [ -f "$src" ] || die "minisign not found at $src" - cp "$src" "${DIR}/minisign" - chmod 0755 "${DIR}/minisign" - BIN="${DIR}/minisign" - ;; - *) - die "unsupported OS for minisign install: $OS" - ;; -esac - -out="$("$BIN" -v 2>&1 | head -n1 || true)" -# Exact token match for version (not substring) -esc="$(printf '%s' "$MINISIGN_VERSION_EXPECTED" | sed 's/\./\\./g')" -printf '%s\n' "$out" | grep -Eq "(^|[^0-9])${esc}([^0-9]|$)" || - die "minisign version assertion failed (want exact ${MINISIGN_VERSION_EXPECTED}): ${out}" - -log "minisign ready: ${BIN} (${out})" -printf 'minisign-bin=%s\n' "$BIN" +exec "$ENGINE" --acquire-minisign-only "$@" diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index 999c8e8..9bd7ee4 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -107,18 +107,24 @@ VERSION="" INSTALL_DIR="" GONEAT_VERSION="" REPO="${SFETCH_REPO_DEFAULT}" +ACQUIRE_MINISIGN_ONLY=0 usage() { cat <<'EOF' >&2 Usage: bootstrap-sfetch-verified.sh --version vX.Y.Z --dir PATH [options] + or: bootstrap-sfetch-verified.sh --acquire-minisign-only --dir PATH -Required: +Required (install mode): --version TAG Exact immutable tag (e.g. v0.4.11). Rejects latest/branches. --dir PATH Install directory for sfetch (and optional goneat) +Required (acquire-minisign-only mode): + --dir PATH Directory to install pinned minisign 0.12 into + Optional: --goneat-version TAG Exact goneat tag to install via verified sfetch --repo owner/name GitHub repo (default: 3leaps/sfetch) --yes Non-interactive (always on for this script; accepted for CLI parity) + --acquire-minisign-only Only install pinned minisign (shared acquisition path for CI) -h, --help Show help Env: @@ -149,6 +155,10 @@ while [ $# -gt 0 ]; do REPO="$2" shift 2 ;; + --acquire-minisign-only) + ACQUIRE_MINISIGN_ONLY=1 + shift + ;; --yes) # Accepted for CLI parity with install-sfetch.sh (always non-interactive). shift @@ -162,45 +172,8 @@ while [ $# -gt 0 ]; do esac done -[ -n "$VERSION" ] || die "--version is required" [ -n "$INSTALL_DIR" ] || die "--dir is required" -# Reject floating / non-immutable refs (CI and Makefile must never use latest). -case "$VERSION" in - "" | latest | LATEST | main | master | HEAD) - die "refusing floating version ${VERSION:-}; pin an exact tag (e.g. v0.4.11)" - ;; -esac -if ! is_exact_semver_tag "$VERSION"; then - die "version must be exact vMAJOR.MINOR.PATCH (got: $VERSION)" -fi -if [ -n "$GONEAT_VERSION" ]; then - case "$GONEAT_VERSION" in - "" | latest | LATEST | main | master | HEAD) - die "refusing floating goneat-version: ${GONEAT_VERSION:-}" - ;; - esac - if ! is_exact_semver_tag "$GONEAT_VERSION"; then - die "goneat-version must be exact vMAJOR.MINOR.PATCH (got: $GONEAT_VERSION)" - fi -fi - -if ! semver_ge "$VERSION" "$SFETCH_BOOTSTRAP_MIN" || ! semver_le "$VERSION" "$SFETCH_BOOTSTRAP_MAX"; then - die "sfetch-version $VERSION outside supported range ${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX} for this bootstrap revision" -fi - -BASE_URL="${SFETCH_BOOTSTRAP_BASE_URL:-https://github.com/${REPO}/releases/download}" -ASSET_BASE="${BASE_URL}/${VERSION}" - -# Route selection (logged; no silent downgrade between routes) -ROUTE="" -if semver_ge "$VERSION" "$SFETCH_MINISIG_SINCE"; then - ROUTE="minisig" -else - ROUTE="sha256sums" -fi -log "bootstrap-sfetch-verified: version=${VERSION} route=${ROUTE} range=${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX}" - # ----------------------------------------------------------------------------- # Platform # ----------------------------------------------------------------------------- @@ -270,6 +243,8 @@ trap cleanup EXIT mkdir -p "$INSTALL_DIR" INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" +# Install mode continues below; acquire-minisign-only jumps after helpers load. + # ----------------------------------------------------------------------------- # HTTPS fetch with bounded retries # ----------------------------------------------------------------------------- @@ -321,15 +296,34 @@ assert_sha256() { # ----------------------------------------------------------------------------- MINISIGN_BIN="" -# Exact version token match (same rules as scripts/version-matches-pin.sh). +# Exact whitespace-delimited version token match (same rules as +# scripts/version-matches-pin.sh). Rejects suffix/prefix soft matches. version_output_matches_pin() { local out="$1" pin="$2" - local ver="${pin#v}" - local esc - esc="$(printf '%s' "$ver" | sed 's/\./\\./g')" - printf '%s\n' "$out" | grep -Eq "(^|[^0-9])v?${esc}([^0-9]|$)" + local want="${pin#v}" + local tok + [ -n "$want" ] || return 1 + while IFS= read -r tok; do + [ -n "$tok" ] || continue + case "$tok" in + v*) + if [ "${tok#v}" = "$want" ]; then + return 0 + fi + ;; + esac + if [ "$tok" = "$want" ]; then + return 0 + fi + done <&1 | head -n1 || true)" - version_output_matches_pin "$out" "$MINISIGN_VERSION_EXPECTED" || - die "minisign version assertion failed (want exact ${MINISIGN_VERSION_EXPECTED}): ${out}" + local out first + # Fail closed if the verifier cannot report a version (no || true). + out="$("$MINISIGN_BIN" -v 2>&1)" || + die "minisign -v failed (cannot assert pinned version ${MINISIGN_VERSION_EXPECTED})" + first="$(printf '%s\n' "$out" | head -n1)" + version_output_matches_pin "$first" "$MINISIGN_VERSION_EXPECTED" || + die "minisign version assertion failed (want exact ${MINISIGN_VERSION_EXPECTED}): ${first}" } +# --acquire-minisign-only: shared acquisition path for CI Windows dogfood etc. +if [ "$ACQUIRE_MINISIGN_ONLY" = "1" ]; then + MINISIGN_INSTALL_DIR="$INSTALL_DIR" + ensure_minisign + printf 'minisign-bin=%s\n' "$MINISIGN_BIN" + exit 0 +fi + +# ----------------------------------------------------------------------------- +# Install mode: version / route validation +# ----------------------------------------------------------------------------- +[ -n "$VERSION" ] || die "--version is required" + +# Reject floating / non-immutable refs (CI and Makefile must never use latest). +case "$VERSION" in + "" | latest | LATEST | main | master | HEAD) + die "refusing floating version ${VERSION:-}; pin an exact tag (e.g. v0.4.11)" + ;; +esac +if ! is_exact_semver_tag "$VERSION"; then + die "version must be exact vMAJOR.MINOR.PATCH (got: $VERSION)" +fi +if [ -n "$GONEAT_VERSION" ]; then + case "$GONEAT_VERSION" in + "" | latest | LATEST | main | master | HEAD) + die "refusing floating goneat-version: ${GONEAT_VERSION:-}" + ;; + esac + if ! is_exact_semver_tag "$GONEAT_VERSION"; then + die "goneat-version must be exact vMAJOR.MINOR.PATCH (got: $GONEAT_VERSION)" + fi +fi + +if ! semver_ge "$VERSION" "$SFETCH_BOOTSTRAP_MIN" || ! semver_le "$VERSION" "$SFETCH_BOOTSTRAP_MAX"; then + die "sfetch-version $VERSION outside supported range ${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX} for this bootstrap revision" +fi + +BASE_URL="${SFETCH_BOOTSTRAP_BASE_URL:-https://github.com/${REPO}/releases/download}" +ASSET_BASE="${BASE_URL}/${VERSION}" + +# Route selection (human log on stderr; machine field on stdout at end only). +ROUTE="" +if semver_ge "$VERSION" "$SFETCH_MINISIG_SINCE"; then + ROUTE="minisig" +else + ROUTE="sha256sums" +fi +log "bootstrap-sfetch-verified: version=${VERSION} verify-route=${ROUTE} range=${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX}" + write_pubkey() { local path="$1" # minisign -p expects a file with optional comment + RW... key line @@ -429,18 +475,18 @@ verify_installer_minisig() { local script="$1" local _sig="$2" local pub="$3" - log "verify route=minisig: minisign -Vm install-sfetch.sh" + log "verify verify-route=minisig: minisign -Vm install-sfetch.sh" if ! "$MINISIGN_BIN" -Vm "$script" -p "$pub" -x "$_sig" >/dev/null; then - die "install-sfetch.sh.minisig verification failed (route=minisig; no fallback)" + die "install-sfetch.sh.minisig verification failed (verify-route=minisig; no fallback)" fi log "install-sfetch.sh.minisig: OK" } verify_installer_sha256sums() { local script="$1" sums="$2" sums_sig="$3" pub="$4" - log "verify route=sha256sums: minisign -Vm SHA256SUMS then hash install-sfetch.sh" + log "verify verify-route=sha256sums: minisign -Vm SHA256SUMS then hash install-sfetch.sh" if ! "$MINISIGN_BIN" -Vm "$sums" -p "$pub" -x "$sums_sig" >/dev/null; then - die "SHA256SUMS.minisig verification failed (route=sha256sums; no fallback)" + die "SHA256SUMS.minisig verification failed (verify-route=sha256sums; no fallback)" fi local expect got expect="$(awk '$2 == "install-sfetch.sh" { print $1; exit }' "$sums")" @@ -519,8 +565,9 @@ if [ -n "$GONEAT_VERSION" ]; then log "goneat OK: ${GREP}" fi -log "bootstrap-sfetch-verified complete: sfetch=${VERSION} route=${ROUTE} dir=${INSTALL_DIR}" -# Emit install path for action consumers (stdout only machine line) +log "bootstrap-sfetch-verified complete: sfetch=${VERSION} verify-route=${ROUTE} dir=${INSTALL_DIR}" +# Machine-readable fields on stdout only (exactly one route= line for the action). +printf 'route=%s\n' "$ROUTE" printf 'sfetch-bin=%s\n' "$SFETCH_BIN" if [ -n "$GONEAT_VERSION" ]; then printf 'goneat-bin=%s\n' "$GONEAT_BIN" diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index 686bd82..93470cd 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -5,8 +5,10 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" SCRIPT="${ROOT}/scripts/bootstrap-sfetch-verified.sh" -ACTION_ENGINE="${ROOT}/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh" VERSION_MATCH="${ROOT}/scripts/version-matches-pin.sh" +# No colocated engine under the action directory — single SSOT under scripts/. +[ ! -e "${ROOT}/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh" ] || + fail "colocated action engine must not exist (single SSOT under scripts/)" [ -x "$SCRIPT" ] || chmod +x "$SCRIPT" [ -x "$VERSION_MATCH" ] || chmod +x "$VERSION_MATCH" @@ -16,10 +18,7 @@ fail() { } pass() { echo "PASS: $*"; } -# --- Engine copy identity (action-owned must match scripts/ SSOT) --- -[ -f "$ACTION_ENGINE" ] || fail "action-owned engine missing: $ACTION_ENGINE" -cmp -s "$SCRIPT" "$ACTION_ENGINE" || fail "action engine diverged from scripts/bootstrap-sfetch-verified.sh" -pass "action engine identical to scripts SSOT" +pass "single engine SSOT under scripts/ (no action-dir copy)" # --- version-matches-pin exact token semantics --- "$VERSION_MATCH" "sfetch 0.4.11" "v0.4.11" || fail "should match sfetch 0.4.11" @@ -27,6 +26,10 @@ pass "action engine identical to scripts SSOT" if "$VERSION_MATCH" "sfetch 10x4y110" "v0.4.11"; then fail "must not soft-match 10x4y110"; fi if "$VERSION_MATCH" "sfetch 10.4.11" "v0.4.11"; then fail "must not match inside 10.4.11"; fi if "$VERSION_MATCH" "sfetch 0.4.110" "v0.4.11"; then fail "must not match 0.4.110"; fi +if "$VERSION_MATCH" "sfetch 0.4.11-rc1" "v0.4.11"; then fail "must not match suffixed 0.4.11-rc1"; fi +if "$VERSION_MATCH" "sfetch v0.4.11-beta" "v0.4.11"; then fail "must not match v0.4.11-beta"; fi +if "$VERSION_MATCH" "sfetch x0.4.11" "v0.4.11"; then fail "must not match prefixed x0.4.11"; fi +"$VERSION_MATCH" "minisign 0.12" "0.12" || fail "should match minisign 0.12" pass "version-matches-pin exact token rules" # --- Version rejection (no network) --- @@ -55,16 +58,16 @@ OUT1040="$WORKDIR/out410.txt" SFETCH_BOOTSTRAP_BASE_URL="file://${WORKDIR}/empty" \ "$SCRIPT" --version v0.4.10 --dir "$WORKDIR/d410" >"$OUT1040" 2>&1 set -e -grep -q 'route=sha256sums' "$OUT1040" || fail "v0.4.10 should select sha256sums route (log: $(cat "$OUT1040"))" -pass "v0.4.10 → route=sha256sums" +grep -Eq 'verify-route=sha256sums' "$OUT1040" || fail "v0.4.10 should select sha256sums (log: $(cat "$OUT1040"))" +pass "v0.4.10 → verify-route=sha256sums" set +e OUT411="$WORKDIR/out411.txt" SFETCH_BOOTSTRAP_BASE_URL="file://${WORKDIR}/empty" \ "$SCRIPT" --version v0.4.11 --dir "$WORKDIR/d411" >"$OUT411" 2>&1 set -e -grep -q 'route=minisig' "$OUT411" || fail "v0.4.11 should select minisig route (log: $(cat "$OUT411"))" -pass "v0.4.11 → route=minisig" +grep -Eq 'verify-route=minisig' "$OUT411" || fail "v0.4.11 should select minisig (log: $(cat "$OUT411"))" +pass "v0.4.11 → verify-route=minisig" # --- Local dual-route fixtures with ephemeral minisign --- command -v minisign >/dev/null 2>&1 || fail "minisign required" @@ -178,10 +181,13 @@ SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ RC=$? set -e [ "$RC" -eq 0 ] || fail "positive v0.4.11 minisig should succeed (log: $(cat "$OUT_GOOD"))" -grep -q 'route=minisig' "$OUT_GOOD" || fail "positive minisig route log missing" +# Machine field on stdout: exactly one route= +ROUTE_LINES="$(grep -c '^route=' "$OUT_GOOD" || true)" +[ "$ROUTE_LINES" -eq 1 ] || fail "expected exactly one stdout route= field, got ${ROUTE_LINES}" +grep -q '^route=minisig$' "$OUT_GOOD" || fail "positive minisig machine route missing" [ -f "$GOOD411/.stub-ran" ] || fail "installer should execute after successful verify" [ -x "$GOOD411/sfetch" ] || fail "sfetch binary should be installed" -"$GOOD411/sfetch" --version | grep -q '0.4.11' || fail "stub sfetch version" +"$VERSION_MATCH" "$("$GOOD411/sfetch" --version 2>&1)" "v0.4.11" || fail "stub sfetch version" pass "positive v0.4.11 minisig route (patched ephemeral key)" # Positive: v0.4.10 sha256sums route with patched trust anchor @@ -195,7 +201,8 @@ SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ RC=$? set -e [ "$RC" -eq 0 ] || fail "positive v0.4.10 sha256sums should succeed (log: $(cat "$OUT_GOOD410"))" -grep -q 'route=sha256sums' "$OUT_GOOD410" || fail "positive sha256sums route log missing" +[ "$(grep -c '^route=' "$OUT_GOOD410" || true)" -eq 1 ] || fail "expected one route= field" +grep -q '^route=sha256sums$' "$OUT_GOOD410" || fail "positive sha256sums machine route missing" [ -f "$GOOD410/.stub-ran" ] || fail "installer should execute after sha256sums verify" pass "positive v0.4.10 sha256sums route (patched ephemeral key)" @@ -213,7 +220,9 @@ set -e [ "$RC" -ne 0 ] || fail "wrong-key minisig should fail" [ ! -f "$BAD_DIR/.stub-ran" ] || fail "installer must not run before verify" [ ! -f "$BAD_DIR/sfetch" ] || fail "sfetch must not be installed on failed verify" -grep -q 'route=minisig' "$OUTBAD" || fail "expected minisig route in log" +grep -Eq 'verify-route=minisig' "$OUTBAD" || fail "expected minisig verify-route in log" +# Failed run must not emit machine route= +if grep -q '^route=' "$OUTBAD"; then fail "failed run must not emit machine route="; fi pass "wrong-key minisig fails closed without executing installer" # Negative: missing signature asset fails closed @@ -247,54 +256,86 @@ set -e [ ! -f "$NOSIG_DIR/.stub-ran" ] || fail "installer must not run when sig missing" pass "missing install-sfetch.sh.minisig fails closed" -# --- Action engine resolution simulation (no GITHUB_WORKSPACE fallback) --- -# Simulate: GITHUB_ACTION_PATH has real engine; workspace has hostile same-named script. +# --- Action engine resolution: package root scripts/, never GITHUB_WORKSPACE --- +# Fake action checkout layout: /.github/actions/setup-sfetch + /scripts/engine +FAKE_PKG="$WORKDIR/fake-action-repo" +mkdir -p "$FAKE_PKG/.github/actions/setup-sfetch" "$FAKE_PKG/scripts" +cp "$SCRIPT" "$FAKE_PKG/scripts/bootstrap-sfetch-verified.sh" +chmod +x "$FAKE_PKG/scripts/bootstrap-sfetch-verified.sh" HOSTILE_WS="$WORKDIR/hostile-ws" -mkdir -p "$HOSTILE_WS/scripts" "$WORKDIR/action-path" -cp "$ACTION_ENGINE" "$WORKDIR/action-path/bootstrap-sfetch-verified.sh" +mkdir -p "$HOSTILE_WS/scripts" cat >"$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" <<'HOSTILE' #!/usr/bin/env bash echo "HOSTILE_ENGINE_EXECUTED" >&2 -echo "route=minisig" >&2 -echo "sfetch-bin=/tmp/evil" +printf 'route=minisig\n' +printf 'sfetch-bin=/tmp/evil\n' exit 0 HOSTILE -chmod +x "$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" \ - "$WORKDIR/action-path/bootstrap-sfetch-verified.sh" +chmod +x "$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" -# Resolution logic mirrored from action.yml (must only use ACTION_PATH) +# Resolution logic mirrored from action.yml resolve_engine() { - local ACTION_ROOT="$1" - local ENGINE="${ACTION_ROOT}/bootstrap-sfetch-verified.sh" + local GITHUB_ACTION_PATH="$1" + local GITHUB_WORKSPACE="${2-}" + local PACKAGE_ROOT ENGINE + PACKAGE_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" + ENGINE="${PACKAGE_ROOT}/scripts/bootstrap-sfetch-verified.sh" if [ ! -f "${ENGINE}" ] || [ ! -r "${ENGINE}" ]; then - echo "error: action-owned engine missing" >&2 + echo "error: action-repo engine missing" >&2 return 1 fi ENGINE="$(cd "$(dirname "${ENGINE}")" && pwd)/$(basename "${ENGINE}")" case "${ENGINE}" in - "${ACTION_ROOT}"/* | "$(cd "${ACTION_ROOT}" && pwd)"/*) ;; + "${PACKAGE_ROOT}"/*) ;; *) - echo "error: engine outside ACTION_PATH" >&2 + echo "error: engine outside package root" >&2 return 1 ;; esac + if [ -n "${GITHUB_WORKSPACE}" ]; then + local WS_REAL + WS_REAL="$(cd "${GITHUB_WORKSPACE}" && pwd)" + if [ "${PACKAGE_ROOT}" != "${WS_REAL}" ]; then + case "${ENGINE}" in + "${WS_REAL}"/*) + echo "error: engine in consumer workspace" >&2 + return 1 + ;; + esac + fi + fi printf '%s\n' "$ENGINE" } -RESOLVED="$(resolve_engine "$WORKDIR/action-path")" || fail "action-path resolve failed" +RESOLVED="$(resolve_engine "$FAKE_PKG/.github/actions/setup-sfetch" "$HOSTILE_WS")" || + fail "package-root resolve failed" case "$RESOLVED" in *hostile*) fail "resolved engine under hostile workspace: $RESOLVED" ;; esac -# Confirm hostile would not be chosen even if WORKSPACE were preferred by old bug -[ -f "$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" ] || fail "hostile fixture missing" -# Don't actually run full bootstrap — just confirm path identity -[ "$RESOLVED" = "$(cd "$WORKDIR/action-path" && pwd)/bootstrap-sfetch-verified.sh" ] || +[ "$RESOLVED" = "$(cd "$FAKE_PKG/scripts" && pwd)/bootstrap-sfetch-verified.sh" ] || fail "unexpected resolve path: $RESOLVED" -# Missing action engine must fail (no workspace fallback) -if resolve_engine "$WORKDIR/missing-action" 2>/dev/null; then - fail "missing action engine must fail" +if resolve_engine "$WORKDIR/missing-action/nested/deep" "$HOSTILE_WS" 2>/dev/null; then + fail "missing package engine must fail" fi -pass "action engine resolves only under GITHUB_ACTION_PATH (hostile workspace ignored)" +pass "action resolves package-root scripts/ engine (hostile workspace ignored)" + +# --- Action wrapper simulation: parse real successful engine stdout --- +# Human logs may mention verify-route= twice; machine stdout has exactly one route=. +SIM_OUT="$WORKDIR/sim-out.txt" +SIM_ERR="$WORKDIR/sim-err.txt" +SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ + SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ + "$PATCHED" --version v0.4.10 --dir "$WORKDIR/sim-install" >"$SIM_OUT" 2>"$SIM_ERR" +# Mimic action parse of stdout only +SIM_ROUTE_COUNT="$(grep -c '^route=' "$SIM_OUT" || true)" +[ "$SIM_ROUTE_COUNT" -eq 1 ] || fail "action sim: expected one route= on stdout, got ${SIM_ROUTE_COUNT} (out=$(cat "$SIM_OUT"); err=$(cat "$SIM_ERR"))" +SIM_ROUTE="$(awk -F= '/^route=/{print $2; exit}' "$SIM_OUT")" +[ "$SIM_ROUTE" = "sha256sums" ] || fail "action sim: route=$SIM_ROUTE" +SIM_BIN="$(awk -F= '/^sfetch-bin=/{print $2; exit}' "$SIM_OUT")" +[ -n "$SIM_BIN" ] && [ -f "$SIM_BIN" ] || fail "action sim: sfetch-bin missing" +# Human stderr may contain verify-route= without breaking parse +grep -Eq 'verify-route=sha256sums' "$SIM_ERR" || fail "action sim: expected human verify-route log" +pass "action wrapper parses single stdout route= from successful engine run" # Cleanup servers kill "$SRV_PID" 2>/dev/null || true diff --git a/scripts/version-matches-pin.sh b/scripts/version-matches-pin.sh index b64d40f..8d4a401 100755 --- a/scripts/version-matches-pin.sh +++ b/scripts/version-matches-pin.sh @@ -1,37 +1,47 @@ #!/usr/bin/env bash -# version-matches-pin.sh — exact version token match for tool --version output. +# version-matches-pin.sh — exact whitespace-delimited version token match. # -# Usage: version-matches-pin.sh -# pin-tag: vMAJOR.MINOR.PATCH (leading v optional in output) -# Exit 0 if output contains an exact version token equal to the pin. +# Usage: version-matches-pin.sh +# pin: vMAJOR.MINOR.PATCH (sfetch/goneat) or MAJOR.MINOR / MAJOR.MINOR.PATCH +# Exit 0 if any whitespace-delimited token equals the pin (optional leading v). # Exit 1 otherwise. # -# Rejects substring/regex soft matches (e.g. "10x4y110" must not match v0.4.11; -# "10.4.11" must not match v0.4.11). +# Rejects: +# - substring soft matches (10x4y110 vs 0.4.11) +# - parent versions inside longer numbers (10.4.11 vs 0.4.11) +# - suffixed/prefixed prerelease tokens (0.4.11-rc1 vs 0.4.11) set -euo pipefail out="${1-}" pin="${2-}" if [ -z "$pin" ]; then - echo "usage: version-matches-pin.sh " >&2 + echo "usage: version-matches-pin.sh " >&2 exit 2 fi -ver="${pin#v}" -case "$ver" in - [0-9]*.[0-9]*.[0-9]*) - if ! [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "error: pin must be exact vMAJOR.MINOR.PATCH (got: $pin)" >&2 - exit 2 - fi - ;; - *) - echo "error: pin must be exact vMAJOR.MINOR.PATCH (got: $pin)" >&2 - exit 2 - ;; -esac +want="${pin#v}" +if [ -z "$want" ]; then + echo "error: empty pin after stripping optional v" >&2 + exit 2 +fi + +# Normalize whitespace → tokens; compare exact equality after optional leading v. +while IFS= read -r tok; do + [ -n "$tok" ] || continue + # Strip a single leading v only when the remainder matches the pin form. + case "$tok" in + v*) + if [ "${tok#v}" = "$want" ]; then + exit 0 + fi + ;; + esac + if [ "$tok" = "$want" ]; then + exit 0 + fi +done < Date: Fri, 31 Jul 2026 12:38:44 -0400 Subject: [PATCH 05/14] fix: remove engine env trust seams; thin action matcher Drop SFETCH_BOOTSTRAP_BASE_URL and SKIP_MINISIGN_INSTALL from the production engine (fixtures patch temporary copies). Action uses awk route counts without || true and trusts engine post-install asserts. Remove version-matches-pin helper; engine matcher is the sole SSOT. Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- .github/actions/setup-sfetch/action.yml | 42 +---- docs/cicd-usage-guide.md | 13 +- scripts/bootstrap-sfetch-verified.sh | 38 ++-- scripts/test-bootstrap-sfetch-verified.sh | 214 ++++++++++++---------- scripts/version-matches-pin.sh | 47 ----- 5 files changed, 144 insertions(+), 210 deletions(-) delete mode 100755 scripts/version-matches-pin.sh diff --git a/.github/actions/setup-sfetch/action.yml b/.github/actions/setup-sfetch/action.yml index 5c261f1..bcb15c4 100644 --- a/.github/actions/setup-sfetch/action.yml +++ b/.github/actions/setup-sfetch/action.yml @@ -106,11 +106,6 @@ runs: fi chmod +x "${ENGINE}" - # Scrub test-only override variables so production action path cannot be - # weakened by ambient job env (pinned archive always; no BASE_URL hijack). - unset SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL - unset SFETCH_BOOTSTRAP_BASE_URL - VERSION="${INPUT_SFETCH_VERSION:-}" if [ -z "${VERSION}" ]; then echo "error: sfetch-version is required" >&2 @@ -141,6 +136,7 @@ runs: fi # Capture machine stdout separately from human stderr logs. + # Engine is authoritative for version/route assertions before success. LOG="$(mktemp)" set +e OUT="$("${ENGINE}" "${ARGS[@]}" 2>"${LOG}")" @@ -155,7 +151,8 @@ runs: rm -f "${LOG}" # Machine-readable fields only: exactly one ^route= line on stdout. - ROUTE_COUNT="$(printf '%s\n' "${OUT}" | grep -c '^route=' || true)" + # awk count — no || true soft suppression. + ROUTE_COUNT="$(printf '%s\n' "${OUT}" | awk 'BEGIN{c=0} /^route=/{c++} END{print c}')" if [ "${ROUTE_COUNT}" -ne 1 ]; then echo "error: expected exactly one stdout route= field, got ${ROUTE_COUNT}" >&2 exit 1 @@ -176,44 +173,11 @@ runs: echo "error: sfetch binary path missing after bootstrap" >&2 exit 1 fi - - # Exact whitespace-token version match (shared rules with engine). - version_matches_pin() { - local out="$1" pin="$2" want="${2#v}" tok - while IFS= read -r tok; do - [ -n "$tok" ] || continue - case "$tok" in - v*) [ "${tok#v}" = "$want" ] && return 0 ;; - esac - [ "$tok" = "$want" ] && return 0 - done <&1)" || { - echo "error: sfetch --version failed after bootstrap" >&2 - exit 1 - } - if ! version_matches_pin "${REPORT}" "${VERSION}"; then - echo "error: sfetch version assertion failed: expected ${VERSION}, got: ${REPORT}" >&2 - exit 1 - fi - if [ -n "${INPUT_GONEAT_VERSION:-}" ]; then if [ -z "${GONEAT_BIN}" ] || [ ! -f "${GONEAT_BIN}" ]; then echo "error: goneat was requested but binary path missing after bootstrap" >&2 exit 1 fi - GREP="$("${GONEAT_BIN}" version 2>&1 | head -n1)" || { - echo "error: goneat version command failed" >&2 - exit 1 - } - if ! version_matches_pin "${GREP}" "${INPUT_GONEAT_VERSION}"; then - echo "error: goneat version assertion failed: expected ${INPUT_GONEAT_VERSION}, got: ${GREP}" >&2 - exit 1 - fi fi echo "${DIR}" >> "${GITHUB_PATH}" diff --git a/docs/cicd-usage-guide.md b/docs/cicd-usage-guide.md index 14d4bd5..3b36850 100644 --- a/docs/cicd-usage-guide.md +++ b/docs/cicd-usage-guide.md @@ -359,12 +359,13 @@ Windows maps `RUNNER_ARCH` X64 → `x86_64`, ARM64 → `aarch64`. **macOS Intel not supported** by the upstream 0.12 macOS archive (arm64-only) and fails closed. -Production and the composite action **always** download and hash-verify the -pinned official archive. They never prefer ambient PATH minisign (a PATH shim -must not become the verifier). Do **not** use Chocolatey/winget community -packages for the verified bootstrap path. A test-only seam -(`SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1`) exists for local fixtures and is -scrubbed by the action. +Production, Makefile D2b, and the composite action **always** download and +hash-verify the pinned official archive. They never prefer ambient PATH +minisign (a PATH shim must not become the verifier) and do not honor runtime +env overrides for download base or verifier acquisition. Local regression +harnesses patch a temporary engine copy when they need fixture URLs or ambient +minisign. Do **not** use Chocolatey/winget community packages for the verified +bootstrap path. ## Incomplete release window diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index 9bd7ee4..cea4126 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -12,18 +12,18 @@ # Usage: # bootstrap-sfetch-verified.sh --version v0.4.11 --dir ~/.local/bin # bootstrap-sfetch-verified.sh --version v0.4.10 --dir ./bin --goneat-version v0.5.15 +# bootstrap-sfetch-verified.sh --acquire-minisign-only --dir PATH # -# Env (test fixtures only — never set in production/CI action path): -# SFETCH_BOOTSTRAP_BASE_URL Override GitHub download base -# (default: https://github.com/3leaps/sfetch/releases/download) -# SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL If set to 1, use ambient minisign -# already on PATH (unit fixtures only). Production always downloads the -# pinned official 0.12 archive and hash-verifies it. +# No runtime env overrides for trust-critical paths (download base, minisign +# acquisition). Production always uses GitHub release URLs and always downloads +# the hash-pinned official minisign 0.12 archive. Local harnesses patch a +# temporary copy of this script for fixture URLs / ambient minisign. # # Makefile consumers outside this repo should retrieve this script at an # immutable git SHA and verify a pinned SHA-256 of the script before executing -# it (see docs/cicd-usage-guide.md). The composite action trusts the script -# via the pinned action SHA instead (engine is colocated under the action path). +# it (see docs/cicd-usage-guide.md). The composite action is a thin wrapper: +# it resolves this single canonical engine from the action repository checkout +# at scripts/bootstrap-sfetch-verified.sh (never from the consumer workspace). # set -euo pipefail @@ -126,9 +126,6 @@ Optional: --yes Non-interactive (always on for this script; accepted for CLI parity) --acquire-minisign-only Only install pinned minisign (shared acquisition path for CI) -h, --help Show help - -Env: - SFETCH_BOOTSTRAP_BASE_URL Override download base (tests) EOF exit 2 } @@ -296,8 +293,8 @@ assert_sha256() { # ----------------------------------------------------------------------------- MINISIGN_BIN="" -# Exact whitespace-delimited version token match (same rules as -# scripts/version-matches-pin.sh). Rejects suffix/prefix soft matches. +# Exact whitespace-delimited version token match (authoritative for this engine). +# Rejects substring soft matches and suffix/prefix extensions (e.g. 0.4.11-rc1). version_output_matches_pin() { local out="$1" pin="$2" local want="${pin#v}" @@ -325,19 +322,9 @@ EOF MINISIGN_INSTALL_DIR="${WORK}/tools" ensure_minisign() { - # Test-only seam: ambient minisign with exact version identity. - # Production and the composite action never set this (action scrubs it). - if [ "${SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL:-0}" = "1" ]; then - command -v minisign >/dev/null 2>&1 || - die "minisign required on PATH when SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1" - MINISIGN_BIN="$(command -v minisign)" - assert_minisign_version - log "using ambient minisign (test seam SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1): ${MINISIGN_BIN}" - return 0 - fi - # Always download + hash-verify the pinned upstream archive. # Do not prefer ambient PATH minisign (PATH shims must not become the verifier). + # No runtime env seam: fixture tests patch a temporary engine copy. local tools="${MINISIGN_INSTALL_DIR}" mkdir -p "$tools" case "$OS" in @@ -447,7 +434,8 @@ if ! semver_ge "$VERSION" "$SFETCH_BOOTSTRAP_MIN" || ! semver_le "$VERSION" "$SF die "sfetch-version $VERSION outside supported range ${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX} for this bootstrap revision" fi -BASE_URL="${SFETCH_BOOTSTRAP_BASE_URL:-https://github.com/${REPO}/releases/download}" +# Fixed download base (no env override — fixtures patch a temporary copy). +BASE_URL="https://github.com/${REPO}/releases/download" ASSET_BASE="${BASE_URL}/${VERSION}" # Route selection (human log on stderr; machine field on stdout at end only). diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index 93470cd..b9388ea 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -1,16 +1,11 @@ #!/usr/bin/env bash # Unit/fixture tests for bootstrap-sfetch-verified.sh (route selection, rejects, dual-route). +# Production engine has no runtime env trust overrides; fixtures patch a temporary copy. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" SCRIPT="${ROOT}/scripts/bootstrap-sfetch-verified.sh" -VERSION_MATCH="${ROOT}/scripts/version-matches-pin.sh" -# No colocated engine under the action directory — single SSOT under scripts/. -[ ! -e "${ROOT}/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh" ] || - fail "colocated action engine must not exist (single SSOT under scripts/)" -[ -x "$SCRIPT" ] || chmod +x "$SCRIPT" -[ -x "$VERSION_MATCH" ] || chmod +x "$VERSION_MATCH" fail() { echo "FAIL: $*" >&2 @@ -18,19 +13,32 @@ fail() { } pass() { echo "PASS: $*"; } -pass "single engine SSOT under scripts/ (no action-dir copy)" +# No colocated engine under the action directory — single SSOT under scripts/. +[ ! -e "${ROOT}/.github/actions/setup-sfetch/bootstrap-sfetch-verified.sh" ] || + fail "colocated action engine must not exist (single SSOT under scripts/)" +# Helper removed: exact matcher lives only in the engine. +[ ! -e "${ROOT}/scripts/version-matches-pin.sh" ] || + fail "version-matches-pin.sh must not exist (engine is sole matcher SSOT)" +[ -x "$SCRIPT" ] || chmod +x "$SCRIPT" +# Production engine must not honor trust-weakening env seams. +grep -q 'SFETCH_BOOTSTRAP_BASE_URL' "$SCRIPT" && + fail "production engine must not reference SFETCH_BOOTSTRAP_BASE_URL" +grep -q 'SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL' "$SCRIPT" && + fail "production engine must not reference SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL" +pass "single engine SSOT; no production env trust seams" -# --- version-matches-pin exact token semantics --- -"$VERSION_MATCH" "sfetch 0.4.11" "v0.4.11" || fail "should match sfetch 0.4.11" -"$VERSION_MATCH" "sfetch version v0.4.11" "v0.4.11" || fail "should match v-prefixed" -if "$VERSION_MATCH" "sfetch 10x4y110" "v0.4.11"; then fail "must not soft-match 10x4y110"; fi -if "$VERSION_MATCH" "sfetch 10.4.11" "v0.4.11"; then fail "must not match inside 10.4.11"; fi -if "$VERSION_MATCH" "sfetch 0.4.110" "v0.4.11"; then fail "must not match 0.4.110"; fi -if "$VERSION_MATCH" "sfetch 0.4.11-rc1" "v0.4.11"; then fail "must not match suffixed 0.4.11-rc1"; fi -if "$VERSION_MATCH" "sfetch v0.4.11-beta" "v0.4.11"; then fail "must not match v0.4.11-beta"; fi -if "$VERSION_MATCH" "sfetch x0.4.11" "v0.4.11"; then fail "must not match prefixed x0.4.11"; fi -"$VERSION_MATCH" "minisign 0.12" "0.12" || fail "should match minisign 0.12" -pass "version-matches-pin exact token rules" +# Load the engine's authoritative matcher for offline token unit tests. +eval "$(sed -n '/^version_output_matches_pin()/,/^}/p' "$SCRIPT")" +version_output_matches_pin "sfetch 0.4.11" "v0.4.11" || fail "should match sfetch 0.4.11" +version_output_matches_pin "sfetch version v0.4.11" "v0.4.11" || fail "should match v-prefixed" +if version_output_matches_pin "sfetch 10x4y110" "v0.4.11"; then fail "must not soft-match 10x4y110"; fi +if version_output_matches_pin "sfetch 10.4.11" "v0.4.11"; then fail "must not match inside 10.4.11"; fi +if version_output_matches_pin "sfetch 0.4.110" "v0.4.11"; then fail "must not match 0.4.110"; fi +if version_output_matches_pin "sfetch 0.4.11-rc1" "v0.4.11"; then fail "must not match suffixed 0.4.11-rc1"; fi +if version_output_matches_pin "sfetch v0.4.11-beta" "v0.4.11"; then fail "must not match v0.4.11-beta"; fi +if version_output_matches_pin "sfetch x0.4.11" "v0.4.11"; then fail "must not match prefixed x0.4.11"; fi +version_output_matches_pin "minisign 0.12" "0.12" || fail "should match minisign 0.12" +pass "engine version_output_matches_pin exact token rules" # --- Version rejection (no network) --- if "$SCRIPT" 2>/dev/null; then fail "should require --version"; else pass "requires --version"; fi @@ -40,7 +48,7 @@ if "$SCRIPT" --version v0.4.11-rc1 --dir /tmp 2>/dev/null; then fail "prerelease if "$SCRIPT" --version v0.4.8 --dir /tmp 2>/dev/null; then fail "below min should fail"; else pass "rejects below min"; fi if "$SCRIPT" --version v0.4.12 --dir /tmp 2>/dev/null; then fail "above max should fail"; else pass "rejects above max"; fi -# --- Route selection logging via dry parse --- +# --- Helpers: patch temporary engines for fixtures (never production seams) --- WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-boot-test.XXXXXX")" SRV_PID="" SRV_PID2="" @@ -53,22 +61,78 @@ cleanup_harness() { } trap cleanup_harness EXIT +PROD_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" + +# Build a temporary engine: optional test pubkey, fixed BASE_URL, ambient minisign. +# Ambient minisign is injected only into the temporary copy (not production). +make_fixture_engine() { + local dest="$1" base_url="$2" pubkey="${3-}" + local src="$SCRIPT" + cp "$src" "$dest" + # Fixed download base → fixture URL (literal, no env). + # shellcheck disable=SC2016 + sed -i.bak \ + 's|BASE_URL="https://github.com/${REPO}/releases/download"|BASE_URL="'"${base_url}"'"|' \ + "$dest" + # Force ambient minisign at start of ensure_minisign (fixture-only). + # Insert after "ensure_minisign() {" + awk ' + /^ensure_minisign\(\) \{$/ { + print + print " # FIXTURE: ambient minisign (temporary harness copy only)" + print " command -v minisign >/dev/null 2>&1 || die \"fixture requires ambient minisign\"" + print " MINISIGN_BIN=\"$(command -v minisign)\"" + print " assert_minisign_version" + print " log \"fixture ambient minisign: ${MINISIGN_BIN}\"" + print " return 0" + next + } + { print } + ' "$dest" >"${dest}.new" + mv "${dest}.new" "$dest" + if [ -n "$pubkey" ]; then + sed -i.bak "s|${PROD_PUBKEY}|${pubkey}|g" "$dest" + grep -q "$pubkey" "$dest" || fail "patched engine missing test pubkey" + grep -q "$PROD_PUBKEY" "$dest" && fail "patched engine still has production pubkey" + fi + rm -f "${dest}.bak" + chmod +x "$dest" +} + +# --- Route selection via fixture engines with dead base URL --- +ROUTE_ENG="$WORKDIR/route-engine.sh" +make_fixture_engine "$ROUTE_ENG" "file://${WORKDIR}/empty" + set +e OUT1040="$WORKDIR/out410.txt" -SFETCH_BOOTSTRAP_BASE_URL="file://${WORKDIR}/empty" \ - "$SCRIPT" --version v0.4.10 --dir "$WORKDIR/d410" >"$OUT1040" 2>&1 +"$ROUTE_ENG" --version v0.4.10 --dir "$WORKDIR/d410" >"$OUT1040" 2>&1 set -e grep -Eq 'verify-route=sha256sums' "$OUT1040" || fail "v0.4.10 should select sha256sums (log: $(cat "$OUT1040"))" pass "v0.4.10 → verify-route=sha256sums" set +e OUT411="$WORKDIR/out411.txt" -SFETCH_BOOTSTRAP_BASE_URL="file://${WORKDIR}/empty" \ - "$SCRIPT" --version v0.4.11 --dir "$WORKDIR/d411" >"$OUT411" 2>&1 +"$ROUTE_ENG" --version v0.4.11 --dir "$WORKDIR/d411" >"$OUT411" 2>&1 set -e grep -Eq 'verify-route=minisig' "$OUT411" || fail "v0.4.11 should select minisig (log: $(cat "$OUT411"))" pass "v0.4.11 → verify-route=minisig" +# Production engine ignores inherited BASE_URL / SKIP env (no seams). +export SFETCH_BOOTSTRAP_BASE_URL="http://evil.example/should-not-be-used" +export SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 +set +e +OUT_IGN="$WORKDIR/out-ignore-env.txt" +"$SCRIPT" --version v0.4.10 --dir "$WORKDIR/d-ign" >"$OUT_IGN" 2>&1 +set -e +# Should attempt real GitHub URL (or fail fetching), not evil.example +if grep -q 'evil.example' "$OUT_IGN"; then + fail "production engine honored SFETCH_BOOTSTRAP_BASE_URL" +fi +# With SKIP set, production still must not prefer ambient-only short-circuit via that var +# (it will try to download pinned minisign or fetch assets from real GitHub). +pass "production engine ignores inherited BASE_URL/SKIP env vars" +unset SFETCH_BOOTSTRAP_BASE_URL SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL + # --- Local dual-route fixtures with ephemeral minisign --- command -v minisign >/dev/null 2>&1 || fail "minisign required" command -v python3 >/dev/null 2>&1 || fail "python3 required for local HTTP fixture" @@ -79,24 +143,12 @@ minisign -G -W -p "$PUB" -s "$KEY" >/dev/null 2>&1 || minisign -G -n -p "$PUB" -s "$KEY" >/dev/null 2>&1 || fail "keygen" -# Extract the RW... public key line for embedding TEST_PUBKEY="$(grep -E '^RW' "$PUB" | head -n1 | tr -d '\r\n')" [ -n "$TEST_PUBKEY" ] || fail "could not read test pubkey" -# Patch a temporary engine copy: replace production trust anchor with test key -# (no production override seam — ephemeral patched copy only). -PROD_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" -PATCHED="$WORKDIR/bootstrap-patched.sh" -sed "s|${PROD_PUBKEY}|${TEST_PUBKEY}|g" "$SCRIPT" >"$PATCHED" -chmod +x "$PATCHED" -grep -q "$TEST_PUBKEY" "$PATCHED" || fail "patched engine missing test pubkey" -grep -q "$PROD_PUBKEY" "$PATCHED" && fail "patched engine still has production pubkey" - -# Local HTTP server root SRV_ROOT="$WORKDIR/www" mkdir -p "$SRV_ROOT/v0.4.11" "$SRV_ROOT/v0.4.10" -# Stub installer that creates a fake sfetch reporting the harness version make_stub_installer() { local dest="$1" cat >"$dest" <<'STUB' @@ -128,18 +180,13 @@ STUB make_stub_installer "$SRV_ROOT/v0.4.11/install-sfetch.sh" make_stub_installer "$SRV_ROOT/v0.4.10/install-sfetch.sh" -# Sign v0.4.11 installer with test key (minisig route) minisign -S -s "$KEY" -t "test-v0.4.11" -m "$SRV_ROOT/v0.4.11/install-sfetch.sh" - -# Sign v0.4.10 via SHA256SUMS route ( cd "$SRV_ROOT/v0.4.10" shasum -a 256 install-sfetch.sh >SHA256SUMS ) minisign -S -s "$KEY" -t "test-v0.4.10" -m "$SRV_ROOT/v0.4.10/SHA256SUMS" -# Start HTTP fixture server without command substitution (bash subshells wait -# on background children, which would hang on serve_forever). start_http_fixture() { local root="$1" portfile="$2" rm -f "$portfile" @@ -156,7 +203,6 @@ with socketserver.TCPServer(("127.0.0.1", 0), H) as httpd: portfile.write_text(str(httpd.server_address[1])) httpd.serve_forever() PY - # Caller reads $! after this function returns in the same shell. } PORTFILE="$WORKDIR/port" @@ -170,67 +216,58 @@ done PORT="$(cat "$PORTFILE")" BASE="http://127.0.0.1:${PORT}" -# Positive: v0.4.11 minisig route with patched trust anchor +PATCHED="$WORKDIR/bootstrap-patched.sh" +make_fixture_engine "$PATCHED" "$BASE" "$TEST_PUBKEY" + +# Positive: v0.4.11 minisig route GOOD411="$WORKDIR/good411" mkdir -p "$GOOD411" set +e OUT_GOOD="$WORKDIR/out-good411.txt" -SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ - SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ - "$PATCHED" --version v0.4.11 --dir "$GOOD411" >"$OUT_GOOD" 2>&1 +"$PATCHED" --version v0.4.11 --dir "$GOOD411" >"$OUT_GOOD" 2>&1 RC=$? set -e [ "$RC" -eq 0 ] || fail "positive v0.4.11 minisig should succeed (log: $(cat "$OUT_GOOD"))" -# Machine field on stdout: exactly one route= -ROUTE_LINES="$(grep -c '^route=' "$OUT_GOOD" || true)" +ROUTE_LINES="$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$OUT_GOOD")" [ "$ROUTE_LINES" -eq 1 ] || fail "expected exactly one stdout route= field, got ${ROUTE_LINES}" grep -q '^route=minisig$' "$OUT_GOOD" || fail "positive minisig machine route missing" [ -f "$GOOD411/.stub-ran" ] || fail "installer should execute after successful verify" [ -x "$GOOD411/sfetch" ] || fail "sfetch binary should be installed" -"$VERSION_MATCH" "$("$GOOD411/sfetch" --version 2>&1)" "v0.4.11" || fail "stub sfetch version" +version_output_matches_pin "$("$GOOD411/sfetch" --version 2>&1)" "v0.4.11" || fail "stub sfetch version" pass "positive v0.4.11 minisig route (patched ephemeral key)" -# Positive: v0.4.10 sha256sums route with patched trust anchor +# Positive: v0.4.10 sha256sums route GOOD410="$WORKDIR/good410" mkdir -p "$GOOD410" set +e OUT_GOOD410="$WORKDIR/out-good410.txt" -SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ - SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ - "$PATCHED" --version v0.4.10 --dir "$GOOD410" >"$OUT_GOOD410" 2>&1 +"$PATCHED" --version v0.4.10 --dir "$GOOD410" >"$OUT_GOOD410" 2>&1 RC=$? set -e [ "$RC" -eq 0 ] || fail "positive v0.4.10 sha256sums should succeed (log: $(cat "$OUT_GOOD410"))" -[ "$(grep -c '^route=' "$OUT_GOOD410" || true)" -eq 1 ] || fail "expected one route= field" +[ "$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$OUT_GOOD410")" -eq 1 ] || fail "expected one route= field" grep -q '^route=sha256sums$' "$OUT_GOOD410" || fail "positive sha256sums machine route missing" [ -f "$GOOD410/.stub-ran" ] || fail "installer should execute after sha256sums verify" pass "positive v0.4.10 sha256sums route (patched ephemeral key)" -# Negative: wrong-key minisig fails closed without executing installer -# Serve production-key-expected path: use UNPATCHED script against test-key sig +# Negative: wrong-key minisig (production pubkey engine against test-key sig) +WRONG_ENG="$WORKDIR/wrong-key-engine.sh" +make_fixture_engine "$WRONG_ENG" "$BASE" # keep production pubkey BAD_DIR="$WORKDIR/bad" mkdir -p "$BAD_DIR" set +e OUTBAD="$WORKDIR/outbad.txt" -SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ - SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ - "$SCRIPT" --version v0.4.11 --dir "$BAD_DIR" >"$OUTBAD" 2>&1 +"$WRONG_ENG" --version v0.4.11 --dir "$BAD_DIR" >"$OUTBAD" 2>&1 RC=$? set -e [ "$RC" -ne 0 ] || fail "wrong-key minisig should fail" [ ! -f "$BAD_DIR/.stub-ran" ] || fail "installer must not run before verify" [ ! -f "$BAD_DIR/sfetch" ] || fail "sfetch must not be installed on failed verify" grep -Eq 'verify-route=minisig' "$OUTBAD" || fail "expected minisig verify-route in log" -# Failed run must not emit machine route= if grep -q '^route=' "$OUTBAD"; then fail "failed run must not emit machine route="; fi pass "wrong-key minisig fails closed without executing installer" -# Negative: missing signature asset fails closed -mkdir -p "$SRV_ROOT/v0.4.11-nosig" -cp "$SRV_ROOT/v0.4.11/install-sfetch.sh" "$SRV_ROOT/v0.4.11-nosig/" -# No .minisig -# Re-map by using a version that selects minisig but only has installer — -# supported max is v0.4.11 only, so use empty BASE subdir via another port root. +# Negative: missing signature NOSIG_ROOT="$WORKDIR/www-nosig" mkdir -p "$NOSIG_ROOT/v0.4.11" cp "$SRV_ROOT/v0.4.11/install-sfetch.sh" "$NOSIG_ROOT/v0.4.11/" @@ -243,13 +280,13 @@ for _ in $(seq 1 50); do done [ -f "$PORTFILE2" ] || fail "second HTTP fixture server failed to start" PORT2="$(cat "$PORTFILE2")" +NOSIG_ENG="$WORKDIR/nosig-engine.sh" +make_fixture_engine "$NOSIG_ENG" "http://127.0.0.1:${PORT2}" "$TEST_PUBKEY" NOSIG_DIR="$WORKDIR/nosig" mkdir -p "$NOSIG_DIR" set +e OUTNOSIG="$WORKDIR/out-nosig.txt" -SFETCH_BOOTSTRAP_BASE_URL="http://127.0.0.1:${PORT2}" \ - SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ - "$PATCHED" --version v0.4.11 --dir "$NOSIG_DIR" >"$OUTNOSIG" 2>&1 +"$NOSIG_ENG" --version v0.4.11 --dir "$NOSIG_DIR" >"$OUTNOSIG" 2>&1 RC=$? set -e [ "$RC" -ne 0 ] || fail "missing minisig should fail" @@ -257,7 +294,6 @@ set -e pass "missing install-sfetch.sh.minisig fails closed" # --- Action engine resolution: package root scripts/, never GITHUB_WORKSPACE --- -# Fake action checkout layout: /.github/actions/setup-sfetch + /scripts/engine FAKE_PKG="$WORKDIR/fake-action-repo" mkdir -p "$FAKE_PKG/.github/actions/setup-sfetch" "$FAKE_PKG/scripts" cp "$SCRIPT" "$FAKE_PKG/scripts/bootstrap-sfetch-verified.sh" @@ -273,7 +309,6 @@ exit 0 HOSTILE chmod +x "$HOSTILE_WS/scripts/bootstrap-sfetch-verified.sh" -# Resolution logic mirrored from action.yml resolve_engine() { local GITHUB_ACTION_PATH="$1" local GITHUB_WORKSPACE="${2-}" @@ -320,31 +355,31 @@ fi pass "action resolves package-root scripts/ engine (hostile workspace ignored)" # --- Action wrapper simulation: parse real successful engine stdout --- -# Human logs may mention verify-route= twice; machine stdout has exactly one route=. SIM_OUT="$WORKDIR/sim-out.txt" SIM_ERR="$WORKDIR/sim-err.txt" -SFETCH_BOOTSTRAP_BASE_URL="$BASE" \ - SFETCH_BOOTSTRAP_SKIP_MINISIGN_INSTALL=1 \ - "$PATCHED" --version v0.4.10 --dir "$WORKDIR/sim-install" >"$SIM_OUT" 2>"$SIM_ERR" -# Mimic action parse of stdout only -SIM_ROUTE_COUNT="$(grep -c '^route=' "$SIM_OUT" || true)" -[ "$SIM_ROUTE_COUNT" -eq 1 ] || fail "action sim: expected one route= on stdout, got ${SIM_ROUTE_COUNT} (out=$(cat "$SIM_OUT"); err=$(cat "$SIM_ERR"))" +"$PATCHED" --version v0.4.10 --dir "$WORKDIR/sim-install" >"$SIM_OUT" 2>"$SIM_ERR" +SIM_ROUTE_COUNT="$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$SIM_OUT")" +[ "$SIM_ROUTE_COUNT" -eq 1 ] || fail "action sim: expected one route= on stdout, got ${SIM_ROUTE_COUNT}" SIM_ROUTE="$(awk -F= '/^route=/{print $2; exit}' "$SIM_OUT")" [ "$SIM_ROUTE" = "sha256sums" ] || fail "action sim: route=$SIM_ROUTE" SIM_BIN="$(awk -F= '/^sfetch-bin=/{print $2; exit}' "$SIM_OUT")" [ -n "$SIM_BIN" ] && [ -f "$SIM_BIN" ] || fail "action sim: sfetch-bin missing" -# Human stderr may contain verify-route= without breaking parse grep -Eq 'verify-route=sha256sums' "$SIM_ERR" || fail "action sim: expected human verify-route log" +# Confirm action has no executable || true soft suppressions (comments OK). +if grep -n '|| true' "${ROOT}/.github/actions/setup-sfetch/action.yml" | grep -v '^\s*[0-9]*:\s*#'; then + fail "action.yml must not contain || true outside comments" +fi pass "action wrapper parses single stdout route= from successful engine run" -# Cleanup servers +# Cleanup primary servers before goneat offline fixture kill "$SRV_PID" 2>/dev/null || true kill "$SRV_PID2" 2>/dev/null || true wait "$SRV_PID" 2>/dev/null || true wait "$SRV_PID2" 2>/dev/null || true +SRV_PID="" +SRV_PID2="" -# Negative: requested goneat with impossible tag fails closed (no soft skip). -# Uses live N-1 sfetch install then fails on goneat — only when network allowed. +# Negative: requested goneat fails closed if [ "${SFETCH_BOOTSTRAP_LIVE:-0}" = "1" ] || [ "${GITHUB_ACTIONS:-}" = "true" ]; then GONEAT_FAIL_DIR="$WORKDIR/goneat-fail" mkdir -p "$GONEAT_FAIL_DIR" @@ -356,10 +391,6 @@ if [ "${SFETCH_BOOTSTRAP_LIVE:-0}" = "1" ] || [ "${GITHUB_ACTIONS:-}" = "true" ] [ "$RC" -ne 0 ] || fail "requested unavailable goneat must fail closed" pass "requested goneat v0.0.0 fails closed" else - # Offline unit: install stub sfetch then invoke goneat path by re-using stub dir - # with a fake sfetch that fails on goneat fetch — covered by engine's || die path. - # Explicit offline simulation: engine with BASE_URL fixture installs sfetch stub, - # then fails when goneat install is requested (sfetch binary is a stub that exits 1). make_stub_installer_fail_goneat() { local dest="$1" cat >"$dest" <<'STUB' @@ -379,7 +410,6 @@ mkdir -p "${DIR}" VER="${TAG#v}" cat >"${DIR}/sfetch" <"$OUT_GO" 2>&1 + "$GONEAT_ENG" --version v0.4.11 --dir "$GONEAT_DIR" --goneat-version v0.5.15 >"$OUT_GO" 2>&1 RC=$? set -e - kill "$SRV_PID3" 2>/dev/null || true - wait "$SRV_PID3" 2>/dev/null || true [ "$RC" -ne 0 ] || fail "goneat install failure must fail closed (log: $(cat "$OUT_GO"))" pass "requested goneat fails closed when sfetch install of goneat fails" fi @@ -424,7 +452,7 @@ if [ "${SFETCH_BOOTSTRAP_LIVE:-0}" = "1" ] || [ "${GITHUB_ACTIONS:-}" = "true" ] LIVE_DIR="$WORKDIR/live" mkdir -p "$LIVE_DIR" if "$SCRIPT" --version v0.4.10 --dir "$LIVE_DIR"; then - "$VERSION_MATCH" "$("$LIVE_DIR/sfetch" --version 2>&1)" "v0.4.10" || fail "live v0.4.10 version" + version_output_matches_pin "$("$LIVE_DIR/sfetch" --version 2>&1)" "v0.4.10" || fail "live v0.4.10 version" pass "live v0.4.10 sha256sums route install" else fail "live v0.4.10 install failed" diff --git a/scripts/version-matches-pin.sh b/scripts/version-matches-pin.sh deleted file mode 100755 index 8d4a401..0000000 --- a/scripts/version-matches-pin.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# version-matches-pin.sh — exact whitespace-delimited version token match. -# -# Usage: version-matches-pin.sh -# pin: vMAJOR.MINOR.PATCH (sfetch/goneat) or MAJOR.MINOR / MAJOR.MINOR.PATCH -# Exit 0 if any whitespace-delimited token equals the pin (optional leading v). -# Exit 1 otherwise. -# -# Rejects: -# - substring soft matches (10x4y110 vs 0.4.11) -# - parent versions inside longer numbers (10.4.11 vs 0.4.11) -# - suffixed/prefixed prerelease tokens (0.4.11-rc1 vs 0.4.11) -set -euo pipefail - -out="${1-}" -pin="${2-}" - -if [ -z "$pin" ]; then - echo "usage: version-matches-pin.sh " >&2 - exit 2 -fi - -want="${pin#v}" -if [ -z "$want" ]; then - echo "error: empty pin after stripping optional v" >&2 - exit 2 -fi - -# Normalize whitespace → tokens; compare exact equality after optional leading v. -while IFS= read -r tok; do - [ -n "$tok" ] || continue - # Strip a single leading v only when the remainder matches the pin form. - case "$tok" in - v*) - if [ "${tok#v}" = "$want" ]; then - exit 0 - fi - ;; - esac - if [ "$tok" = "$want" ]; then - exit 0 - fi -done < Date: Fri, 31 Jul 2026 13:04:20 -0400 Subject: [PATCH 06/14] fix: release-gate anchor assert and strict semver Assert SFETCH_MINISIGN_PUB equals the canonical consumer trust anchor (scripts/sfetch-minisign-anchor.pub SSOT, go:embed in main) before verify-signatures proceeds. Reject leading-zero semver components and fail semver_cmp loudly. Harden PowerShell path env, prefix-strip action outputs, and defer install-dir mkdir until after validation. Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- .github/actions/setup-sfetch/action.yml | 6 +- main.go | 31 ++++--- main_test.go | 17 ++++ scripts/bootstrap-sfetch-verified.sh | 98 ++++++++++++++++------- scripts/install-sfetch.sh | 3 +- scripts/sfetch-minisign-anchor.pub | 2 + scripts/test-bootstrap-sfetch-verified.sh | 9 +++ scripts/test-release-verify-signatures.sh | 49 +++++++++--- scripts/verify-signatures.sh | 58 ++++++++++++++ 9 files changed, 217 insertions(+), 56 deletions(-) create mode 100644 scripts/sfetch-minisign-anchor.pub diff --git a/.github/actions/setup-sfetch/action.yml b/.github/actions/setup-sfetch/action.yml index bcb15c4..c9976a4 100644 --- a/.github/actions/setup-sfetch/action.yml +++ b/.github/actions/setup-sfetch/action.yml @@ -73,7 +73,6 @@ runs: # Resolve single canonical engine inside the action repository checkout. # Nested composite actions live at .github/actions//; the package # root is three levels up. Never consult GITHUB_WORKSPACE (consumer). - ACTION_PATH_REAL="$(cd "${GITHUB_ACTION_PATH}" && pwd)" PACKAGE_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" ENGINE="${PACKAGE_ROOT}/scripts/bootstrap-sfetch-verified.sh" if [ ! -f "${ENGINE}" ] || [ ! -r "${ENGINE}" ]; then @@ -166,8 +165,9 @@ runs: ;; esac - SFETCH_BIN="$(printf '%s\n' "${OUT}" | awk -F= '/^sfetch-bin=/{print $2; exit}')" - GONEAT_BIN="$(printf '%s\n' "${OUT}" | awk -F= '/^goneat-bin=/{print $2; exit}')" + # Prefix strip (not field-split) so install paths containing '=' survive. + SFETCH_BIN="$(printf '%s\n' "${OUT}" | sed -n 's/^sfetch-bin=//p' | head -n1)" + GONEAT_BIN="$(printf '%s\n' "${OUT}" | sed -n 's/^goneat-bin=//p' | head -n1)" if [ -z "${SFETCH_BIN}" ] || [ ! -f "${SFETCH_BIN}" ]; then echo "error: sfetch binary path missing after bootstrap" >&2 diff --git a/main.go b/main.go index 7405173..1ce391d 100644 --- a/main.go +++ b/main.go @@ -255,16 +255,29 @@ const ( // minisignPubkeyRegex matches a valid minisign public key line (with or without comment header) var minisignPubkeyRegex = regexp.MustCompile(`^RW[A-Za-z0-9+/]{54}$`) -// Embedded trust anchors for self-verification and transparency. -// Users can compare these against keys published at: -// - https://github.com/3leaps/sfetch/releases (sfetch-minisign.pub) -// - scripts/install-sfetch.sh (SFETCH_MINISIGN_PUBKEY) +// Canonical minisign trust anchor SSOT: scripts/sfetch-minisign-anchor.pub +// EmbeddedMinisignPubkey is derived from that file (go:embed). Standalone +// consumers (install-sfetch.sh, bootstrap-sfetch-verified.sh) embed the same +// RW line and are checked against the SSOT in tests / release verify. // -// Changing these keys requires updating both this file and install-sfetch.sh. -const ( - EmbeddedMinisignPubkey = "RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" - EmbeddedMinisignKeyID = "3leaps/sfetch release signing key" -) +//go:embed scripts/sfetch-minisign-anchor.pub +var embeddedMinisignPubFile string + +// EmbeddedMinisignPubkey is the RW… line from scripts/sfetch-minisign-anchor.pub. +var EmbeddedMinisignPubkey = mustMinisignPubkeyLine(embeddedMinisignPubFile) + +// EmbeddedMinisignKeyID is a human label for the release signing key. +const EmbeddedMinisignKeyID = "3leaps/sfetch release signing key" + +func mustMinisignPubkeyLine(content string) string { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "RW") && len(line) == 56 { + return line + } + } + panic("scripts/sfetch-minisign-anchor.pub: missing RW… public key line") +} // minisignSecretkeyPrefixes are known prefixes for secret key files var minisignSecretkeyPrefixes = []string{ diff --git a/main_test.go b/main_test.go index 41401b4..2c7174e 100644 --- a/main_test.go +++ b/main_test.go @@ -2486,6 +2486,23 @@ func TestEmbeddedTrustAnchors(t *testing.T) { t.Error("EmbeddedMinisignPubkey does not match key in scripts/install-sfetch.sh") } }) + + t.Run("matches SSOT anchor file and bootstrap engine", func(t *testing.T) { + anchor, err := os.ReadFile("scripts/sfetch-minisign-anchor.pub") + if err != nil { + t.Fatalf("read scripts/sfetch-minisign-anchor.pub: %v", err) + } + if !strings.Contains(string(anchor), EmbeddedMinisignPubkey) { + t.Error("EmbeddedMinisignPubkey does not match scripts/sfetch-minisign-anchor.pub") + } + engine, err := os.ReadFile("scripts/bootstrap-sfetch-verified.sh") + if err != nil { + t.Fatalf("read bootstrap engine: %v", err) + } + if !strings.Contains(string(engine), EmbeddedMinisignPubkey) { + t.Error("EmbeddedMinisignPubkey does not match bootstrap-sfetch-verified.sh") + } + }) } func TestEmbeddedUpdateTargetConfigMatchesFile(t *testing.T) { diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index cea4126..6684adf 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -38,9 +38,9 @@ readonly SFETCH_BOOTSTRAP_MAX="v0.4.11" # First release that publishes install-sfetch.sh.minisig readonly SFETCH_MINISIG_SINCE="v0.4.11" -# Embedded trust anchor — must match EmbeddedMinisignPubkey in main.go and -# scripts/install-sfetch.sh. Do NOT fetch sfetch-minisign.pub from the release -# for authentication (circular: same origin as the artifact under test). +# Embedded trust anchor — must match scripts/sfetch-minisign-anchor.pub (SSOT), +# main.go (go:embed of that file), and scripts/install-sfetch.sh. Do NOT fetch +# sfetch-minisign.pub from the release for authentication (circular). # The published .pub is for human out-of-band comparison only. readonly SFETCH_MINISIGN_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" @@ -66,39 +66,59 @@ die() { # ----------------------------------------------------------------------------- # Semver helpers (tags must be vMAJOR.MINOR.PATCH, optional -prerelease rejected) # ----------------------------------------------------------------------------- +# Canonical numeric components only: no leading zeros (v0.4.09 / v0.04.11 rejected). is_exact_semver_tag() { - case "$1" in - v[0-9]*.[0-9]*.[0-9]*) - # Reject extra suffix (pre-release / build) and non-numeric parts - [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] - ;; - *) return 1 ;; - esac + [[ "$1" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] } -# Compare two vX.Y.Z tags: echo -1 / 0 / 1 for ab +# Compare two vX.Y.Z tags: echo -1 / 0 / 1 for ab. +# Fail loudly (return non-zero, no stdout) on non-canonical components — +# never fall through to "equal" on arithmetic error. semver_cmp() { local a="${1#v}" b="${2#v}" local a1 a2 a3 b1 b2 b3 IFS=. read -r a1 a2 a3 <<<"$a" IFS=. read -r b1 b2 b3 <<<"$b" - if ((a1 != b1)); then - if ((a1 < b1)); then echo -1; else echo 1; fi - return + local c + for c in "$a1" "$a2" "$a3" "$b1" "$b2" "$b3"; do + case "$c" in + '' | *[!0-9]*) + log "error: semver_cmp: non-numeric component in $1 vs $2" + return 2 + ;; + 0) ;; + 0*) + log "error: semver_cmp: leading zero in component ($c) for $1 vs $2" + return 2 + ;; + esac + done + # Force base-10 integer comparison (avoid bash octal pitfalls). + if [ "$((10#$a1))" -ne "$((10#$b1))" ]; then + if [ "$((10#$a1))" -lt "$((10#$b1))" ]; then echo -1; else echo 1; fi + return 0 fi - if ((a2 != b2)); then - if ((a2 < b2)); then echo -1; else echo 1; fi - return + if [ "$((10#$a2))" -ne "$((10#$b2))" ]; then + if [ "$((10#$a2))" -lt "$((10#$b2))" ]; then echo -1; else echo 1; fi + return 0 fi - if ((a3 != b3)); then - if ((a3 < b3)); then echo -1; else echo 1; fi - return + if [ "$((10#$a3))" -ne "$((10#$b3))" ]; then + if [ "$((10#$a3))" -lt "$((10#$b3))" ]; then echo -1; else echo 1; fi + return 0 fi echo 0 } -semver_ge() { [[ "$(semver_cmp "$1" "$2")" != "-1" ]]; } -semver_le() { [[ "$(semver_cmp "$1" "$2")" != "1" ]]; } +semver_ge() { + local r + r="$(semver_cmp "$1" "$2")" || die "semver comparison failed for $1 vs $2" + [[ "$r" != "-1" ]] +} +semver_le() { + local r + r="$(semver_cmp "$1" "$2")" || die "semver comparison failed for $1 vs $2" + [[ "$r" != "1" ]] +} # ----------------------------------------------------------------------------- # Args @@ -228,6 +248,13 @@ if [ "$OS" = "darwin" ] && [ "$ARCH" = "x86_64" ]; then die "macOS x86_64 is not supported by the pinned minisign 0.12 macOS archive (arm64-only); use an arm64 runner" fi +# Reject unsafe TMPDIR characters before any path-derived shelling out (F3). +case "${TMPDIR:-}" in + *\'* | *\"* | *$'\n'* | *$'\r'*) + die "TMPDIR contains quote or newline characters (unsafe for path handling)" + ;; +esac + # ----------------------------------------------------------------------------- # Temp workspace (private; cleaned on exit) # ----------------------------------------------------------------------------- @@ -237,10 +264,13 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$INSTALL_DIR" -INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" - -# Install mode continues below; acquire-minisign-only jumps after helpers load. +# Resolve install dir path without creating it yet (validate before side effects). +# acquire-minisign-only / install mode create the directory after validation. +_parent="$(dirname "$INSTALL_DIR")" +if [ -d "$_parent" ]; then + INSTALL_DIR="$(cd "$_parent" && pwd)/$(basename "$INSTALL_DIR")" +fi +unset _parent # ----------------------------------------------------------------------------- # HTTPS fetch with bounded retries @@ -335,9 +365,11 @@ ensure_minisign() { if command -v unzip >/dev/null 2>&1; then unzip -q -o "$zip" -d "${WORK}/minisign-extract" else - # PowerShell Expand-Archive on Windows runners - powershell.exe -NoProfile -Command \ - "Expand-Archive -LiteralPath '$zip' -DestinationPath '${WORK}/minisign-extract' -Force" || + # PowerShell Expand-Archive via env vars (no path interpolation into -Command). + SFETCH_MINISIGN_ZIP_PATH="$zip" \ + SFETCH_MINISIGN_EXTRACT_PATH="${WORK}/minisign-extract" \ + powershell.exe -NoProfile -Command \ + 'Expand-Archive -LiteralPath $env:SFETCH_MINISIGN_ZIP_PATH -DestinationPath $env:SFETCH_MINISIGN_EXTRACT_PATH -Force' || die "failed to extract minisign zip" fi local sub @@ -399,6 +431,8 @@ assert_minisign_version() { # --acquire-minisign-only: shared acquisition path for CI Windows dogfood etc. if [ "$ACQUIRE_MINISIGN_ONLY" = "1" ]; then + mkdir -p "$INSTALL_DIR" + INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" MINISIGN_INSTALL_DIR="$INSTALL_DIR" ensure_minisign printf 'minisign-bin=%s\n' "$MINISIGN_BIN" @@ -406,7 +440,7 @@ if [ "$ACQUIRE_MINISIGN_ONLY" = "1" ]; then fi # ----------------------------------------------------------------------------- -# Install mode: version / route validation +# Install mode: version / route validation (before mkdir side effects) # ----------------------------------------------------------------------------- [ -n "$VERSION" ] || die "--version is required" @@ -434,6 +468,10 @@ if ! semver_ge "$VERSION" "$SFETCH_BOOTSTRAP_MIN" || ! semver_le "$VERSION" "$SF die "sfetch-version $VERSION outside supported range ${SFETCH_BOOTSTRAP_MIN}..${SFETCH_BOOTSTRAP_MAX} for this bootstrap revision" fi +# Side effects only after version/range validation succeeds. +mkdir -p "$INSTALL_DIR" +INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" + # Fixed download base (no env override — fixtures patch a temporary copy). BASE_URL="https://github.com/${REPO}/releases/download" ASSET_BASE="${BASE_URL}/${VERSION}" diff --git a/scripts/install-sfetch.sh b/scripts/install-sfetch.sh index 4698f14..469ffc6 100755 --- a/scripts/install-sfetch.sh +++ b/scripts/install-sfetch.sh @@ -37,7 +37,8 @@ SFETCH_API="https://api.github.com/repos/${SFETCH_REPO}/releases" # This key is pinned here to prevent TOCTOU attacks where an attacker # could replace both the release artifacts and the verification key. # -# IMPORTANT: This key must match EmbeddedMinisignPubkey in main.go +# IMPORTANT: This key must match scripts/sfetch-minisign-anchor.pub (SSOT) +# and EmbeddedMinisignPubkey in main.go (go:embed of that file). # Update both when rotating keys (see docs/security/signing-runbook.md) SFETCH_MINISIGN_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" # Pinned PGP fingerprint (for optional fallback) diff --git a/scripts/sfetch-minisign-anchor.pub b/scripts/sfetch-minisign-anchor.pub new file mode 100644 index 0000000..0f5dbd5 --- /dev/null +++ b/scripts/sfetch-minisign-anchor.pub @@ -0,0 +1,2 @@ +untrusted comment: sfetch release signing key (canonical trust anchor SSOT) +RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index b9388ea..f1980a2 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -47,6 +47,9 @@ if "$SCRIPT" --version main --dir /tmp 2>/dev/null; then fail "main should fail" if "$SCRIPT" --version v0.4.11-rc1 --dir /tmp 2>/dev/null; then fail "prerelease should fail"; else pass "rejects prerelease"; fi if "$SCRIPT" --version v0.4.8 --dir /tmp 2>/dev/null; then fail "below min should fail"; else pass "rejects below min"; fi if "$SCRIPT" --version v0.4.12 --dir /tmp 2>/dev/null; then fail "above max should fail"; else pass "rejects above max"; fi +# F2: leading-zero components must not pass range/route selection +if "$SCRIPT" --version v0.4.09 --dir /tmp 2>/dev/null; then fail "v0.4.09 leading zero should fail"; else pass "rejects v0.4.09 leading zero"; fi +if "$SCRIPT" --version v0.04.11 --dir /tmp 2>/dev/null; then fail "v0.04.11 leading zero should fail"; else pass "rejects v0.04.11 leading zero"; fi # --- Helpers: patch temporary engines for fixtures (never production seams) --- WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-boot-test.XXXXXX")" @@ -61,6 +64,12 @@ cleanup_harness() { } trap cleanup_harness EXIT +# Rejected inputs must not create install dirs (validate-before-side-effect) +NO_SIDE="$WORKDIR/no-side" +if "$SCRIPT" --version v0.4.09 --dir "$NO_SIDE" 2>/dev/null; then fail "v0.4.09 should fail"; fi +[ ! -e "$NO_SIDE" ] || fail "rejected version must not create install dir" +pass "rejected version has no mkdir side effect" + PROD_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" # Build a temporary engine: optional test pubkey, fixed BASE_URL, ambient minisign. diff --git a/scripts/test-release-verify-signatures.sh b/scripts/test-release-verify-signatures.sh index cc485d8..0f2fcb1 100755 --- a/scripts/test-release-verify-signatures.sh +++ b/scripts/test-release-verify-signatures.sh @@ -20,12 +20,20 @@ WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-sig-test.XXXXXX")" KEY="${WORKDIR}/test.key" PUB="${WORKDIR}/test.pub" -# Non-interactive keygen (empty password via -W / force) -# minisign -G -W generates unencrypted secret key (for CI fixtures only) minisign -G -W -p "$PUB" -s "$KEY" >/dev/null 2>&1 || minisign -G -n -p "$PUB" -s "$KEY" >/dev/null 2>&1 || fail "minisign keygen failed" +# Fixture scripts tree: ephemeral key is the temporary "canonical" anchor so +# verify mechanics can be tested without production secrets. Production +# scripts/sfetch-minisign-anchor.pub remains the real SSOT. +FIX_SCRIPTS="${WORKDIR}/scripts" +mkdir -p "$FIX_SCRIPTS" +cp "$ROOT/scripts/verify-signatures.sh" "$FIX_SCRIPTS/" +cp "$PUB" "$FIX_SCRIPTS/sfetch-minisign-anchor.pub" +VERIFY="$FIX_SCRIPTS/verify-signatures.sh" +chmod +x "$VERIFY" + # --- Fixture: signed manifests + installer --- STAGE="${WORKDIR}/stage" mkdir -p "$STAGE" @@ -37,26 +45,34 @@ printf '#!/bin/sh\necho installer\n' >"$STAGE/install-sfetch.sh" shasum -a 512 a.bin install-sfetch.sh >SHA512SUMS ) -# Sign with mocked env (sign-release-manifests.sh) export SFETCH_MINISIGN_KEY="$KEY" export SFETCH_MINISIGN_PUB="$PUB" -# Provide password-free path: rewrite sign to use -W keys; minisign -S without password for -W keys minisign -S -s "$KEY" -t "test" -m "$STAGE/SHA256SUMS" minisign -S -s "$KEY" -t "test" -m "$STAGE/SHA512SUMS" minisign -S -s "$KEY" -t "test" -m "$STAGE/install-sfetch.sh" -# Case 1: full set verifies -if SFETCH_MINISIGN_PUB="$PUB" ./scripts/verify-signatures.sh "$STAGE"; then +# Case 1: full set verifies (fixture anchor = test key) +if SFETCH_MINISIGN_PUB="$PUB" "$VERIFY" "$STAGE"; then pass "full fixture verifies" else fail "full fixture should verify" fi +# Case 1b (F1): production gate rejects operator pub that is not the consumer anchor +# even when signatures would verify against that operator key. +if SFETCH_MINISIGN_PUB="$PUB" ./scripts/verify-signatures.sh "$STAGE" 2>"${WORKDIR}/f1.err"; then + fail "production verify must reject non-canonical SFETCH_MINISIGN_PUB" +else + grep -qi 'canonical consumer trust anchor\|does not match' "${WORKDIR}/f1.err" || + fail "expected F1 anchor mismatch message (got: $(cat "${WORKDIR}/f1.err"))" + pass "production gate rejects non-canonical operator pubkey (F1)" +fi + # Case 2: missing installer minisig ⇒ non-zero (required) NO_INST="${WORKDIR}/no-inst" cp -R "$STAGE/." "$NO_INST/" rm -f "$NO_INST/install-sfetch.sh.minisig" -if SFETCH_MINISIGN_PUB="$PUB" ./scripts/verify-signatures.sh "$NO_INST"; then +if SFETCH_MINISIGN_PUB="$PUB" "$VERIFY" "$NO_INST"; then fail "missing install-sfetch.sh.minisig should exit non-zero" else pass "missing install-sfetch.sh.minisig exits non-zero" @@ -66,13 +82,14 @@ fi TAMPER="${WORKDIR}/tamper" cp -R "$STAGE/." "$TAMPER/" echo "evil" >>"$TAMPER/install-sfetch.sh" -if SFETCH_MINISIGN_PUB="$PUB" ./scripts/verify-signatures.sh "$TAMPER"; then +if SFETCH_MINISIGN_PUB="$PUB" "$VERIFY" "$TAMPER"; then fail "tampered installer should exit non-zero" else pass "tampered installer exits non-zero" fi -# Case 4: wrong key fails +# Case 4: wrong key fails (fixture anchor still test key; wrong pub fails both +# anchor check against fixture SSOT and/or signature verify) WRONG="${WORKDIR}/wrong" cp -R "$STAGE/." "$WRONG/" WRONG_KEY="${WORKDIR}/wrong.key" @@ -80,7 +97,7 @@ WRONG_PUB="${WORKDIR}/wrong.pub" minisign -G -W -p "$WRONG_PUB" -s "$WRONG_KEY" >/dev/null 2>&1 || minisign -G -n -p "$WRONG_PUB" -s "$WRONG_KEY" >/dev/null 2>&1 || fail "wrong keygen failed" -if SFETCH_MINISIGN_PUB="$WRONG_PUB" ./scripts/verify-signatures.sh "$WRONG"; then +if SFETCH_MINISIGN_PUB="$WRONG_PUB" "$VERIFY" "$WRONG"; then fail "wrong key should exit non-zero" else pass "wrong key exits non-zero" @@ -96,7 +113,6 @@ SFETCH_MINISIGN_KEY="$KEY" ./scripts/sign-release-manifests.sh v0.0.0-test "$SIG [ -f "$SIGN_DIR/install-sfetch.sh.minisig" ] || fail "sign-release-manifests did not produce install-sfetch.sh.minisig" [ -f "$SIGN_DIR/SHA256SUMS.minisig" ] || fail "sign-release-manifests did not produce SHA256SUMS.minisig" [ -f "$SIGN_DIR/SHA512SUMS.minisig" ] || fail "sign-release-manifests did not produce SHA512SUMS.minisig" -# PGP not requested → no .asc on installer [ ! -f "$SIGN_DIR/install-sfetch.sh.asc" ] || fail "PGP must not sign installer by default" pass "sign-release-manifests minisign targets (manifests + installer)" @@ -105,7 +121,6 @@ UPLOAD_DIR="${WORKDIR}/upload" mkdir -p "$UPLOAD_DIR" cp "$STAGE/install-sfetch.sh" "$UPLOAD_DIR/" printf '# notes\n' >"$UPLOAD_DIR/release-notes-v0.0.0-test.md" -# Fake binary so ARTIFACTS non-empty printf 'bin\n' >"$UPLOAD_DIR/sfetch_linux_amd64.tar.gz" cp "$STAGE/SHA256SUMS" "$UPLOAD_DIR/" cp "$STAGE/SHA512SUMS" "$UPLOAD_DIR/" @@ -136,7 +151,6 @@ if command -v gpg >/dev/null 2>&1; then GPG_HOME="${WORKDIR}/gnupg" mkdir -p "$GPG_HOME" chmod 700 "$GPG_HOME" - # Batch-generate an ephemeral RSA key (no passphrase) cat >"${WORKDIR}/gpg-batch" </dev/null | head -n1 | tr -d '\r\n' || true)" + if [ -n "$line" ]; then + printf '%s\n' "$line" + return 0 + fi + line="$(grep -E 'RW[A-Za-z0-9+/]{54}' "$f" 2>/dev/null | head -n1 | tr -d '\r\n' || true)" + # Extract the RW token if surrounded by other text + printf '%s\n' "$line" | grep -oE 'RW[A-Za-z0-9+/]{54}' | head -n1 +} require_minisign_tool() { if ! command -v minisign >/dev/null 2>&1; then @@ -39,6 +59,43 @@ require_minisign_tool() { return 0 } +# F1: gate must prove signatures verify against *the* consumer anchor, not an +# arbitrary operator-supplied key that happens to match the signatures. +assert_operator_pub_is_canonical_anchor() { + if [ "${_anchor_checked}" -eq 1 ]; then + return 0 + fi + if [ ! -f "${CANONICAL_ANCHOR_PUB}" ]; then + echo "error: canonical trust anchor missing: ${CANONICAL_ANCHOR_PUB}" >&2 + failed=$((failed + 1)) + return 1 + fi + local expect got + expect="$(extract_minisign_rw_line "${CANONICAL_ANCHOR_PUB}")" + got="$(extract_minisign_rw_line "${SFETCH_MINISIGN_PUB}")" + if [ -z "${expect}" ]; then + echo "error: canonical anchor has no RW… key line: ${CANONICAL_ANCHOR_PUB}" >&2 + failed=$((failed + 1)) + return 1 + fi + if [ -z "${got}" ]; then + echo "error: SFETCH_MINISIGN_PUB has no RW… key line: ${SFETCH_MINISIGN_PUB}" >&2 + failed=$((failed + 1)) + return 1 + fi + if [ "${got}" != "${expect}" ]; then + echo "error: SFETCH_MINISIGN_PUB does not match the canonical consumer trust anchor" >&2 + echo " expected (scripts/sfetch-minisign-anchor.pub): ${expect}" >&2 + echo " got (SFETCH_MINISIGN_PUB): ${got}" >&2 + echo " Signing with a non-consumer key would make the release gate green while" >&2 + echo " every bootstrap consumer fails against the embedded anchor." >&2 + failed=$((failed + 1)) + return 1 + fi + _anchor_checked=1 + return 0 +} + require_minisign_pub() { if [ -z "${SFETCH_MINISIGN_PUB}" ]; then echo "error: SFETCH_MINISIGN_PUB not set, cannot verify minisign signatures" >&2 @@ -50,6 +107,7 @@ require_minisign_pub() { failed=$((failed + 1)) return 1 fi + assert_operator_pub_is_canonical_anchor || return 1 return 0 } From b48ca58727b472cc633279b89c777aa8a43e3cb3 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 13:11:52 -0400 Subject: [PATCH 07/14] fix: verify release minisig against canonical anchor file minisign -V always uses scripts/sfetch-minisign-anchor.pub (SSOT). SFETCH_MINISIGN_PUB remains a required advisory that must match the operative key line (comment+key format), so operator/key divergence cannot reintroduce a green gate with a non-consumer verifier key. Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- scripts/verify-signatures.sh | 80 +++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/scripts/verify-signatures.sh b/scripts/verify-signatures.sh index 67d89b6..42100be 100755 --- a/scripts/verify-signatures.sh +++ b/scripts/verify-signatures.sh @@ -6,7 +6,10 @@ set -euo pipefail # Usage: verify-signatures.sh [dir] # # Env: -# SFETCH_MINISIGN_PUB - path to minisign public key (required for minisign) +# SFETCH_MINISIGN_PUB - path to operator minisign public key (required advisory). +# Must match the canonical consumer trust anchor. Actual minisign -V always +# uses scripts/sfetch-minisign-anchor.pub (SSOT) so parser divergence cannot +# reintroduce "signed with some key ≠ consumer key". # SFETCH_GPG_HOMEDIR - isolated gpg homedir for PGP verification (optional) # # Policy (deliberate divergence — do not re-harmonise without a lock): @@ -36,18 +39,26 @@ _anchor_checked=0 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CANONICAL_ANCHOR_PUB="${SCRIPT_DIR}/sfetch-minisign-anchor.pub" -extract_minisign_rw_line() { +# Extract the operative minisign public key the same way minisign does: +# line 1 = untrusted comment, line 2 = RW… key (when standard .pub format). +minisign_operative_pubkey() { local f="$1" - # Prefer a line that is exactly the RW key; fall back to first RW token. + local l2 + l2="$(sed -n '2p' "$f" | tr -d '\r\n')" + case "$l2" in + RW*) + printf '%s\n' "$l2" + return 0 + ;; + esac + # Bare single-line key file (no comment header). local line line="$(grep -E '^RW[A-Za-z0-9+/]{54}$' "$f" 2>/dev/null | head -n1 | tr -d '\r\n' || true)" if [ -n "$line" ]; then printf '%s\n' "$line" return 0 fi - line="$(grep -E 'RW[A-Za-z0-9+/]{54}' "$f" 2>/dev/null | head -n1 | tr -d '\r\n' || true)" - # Extract the RW token if surrounded by other text - printf '%s\n' "$line" | grep -oE 'RW[A-Za-z0-9+/]{54}' | head -n1 + return 1 } require_minisign_tool() { @@ -59,9 +70,8 @@ require_minisign_tool() { return 0 } -# F1: gate must prove signatures verify against *the* consumer anchor, not an -# arbitrary operator-supplied key that happens to match the signatures. -assert_operator_pub_is_canonical_anchor() { +# F1: operator pub must match the consumer SSOT; minisign always verifies with SSOT. +assert_operator_pub_matches_canonical() { if [ "${_anchor_checked}" -eq 1 ]; then return 0 fi @@ -71,18 +81,16 @@ assert_operator_pub_is_canonical_anchor() { return 1 fi local expect got - expect="$(extract_minisign_rw_line "${CANONICAL_ANCHOR_PUB}")" - got="$(extract_minisign_rw_line "${SFETCH_MINISIGN_PUB}")" - if [ -z "${expect}" ]; then - echo "error: canonical anchor has no RW… key line: ${CANONICAL_ANCHOR_PUB}" >&2 + expect="$(minisign_operative_pubkey "${CANONICAL_ANCHOR_PUB}")" || { + echo "error: canonical anchor has no operative RW… key: ${CANONICAL_ANCHOR_PUB}" >&2 failed=$((failed + 1)) return 1 - fi - if [ -z "${got}" ]; then - echo "error: SFETCH_MINISIGN_PUB has no RW… key line: ${SFETCH_MINISIGN_PUB}" >&2 + } + got="$(minisign_operative_pubkey "${SFETCH_MINISIGN_PUB}")" || { + echo "error: SFETCH_MINISIGN_PUB has no operative RW… key: ${SFETCH_MINISIGN_PUB}" >&2 failed=$((failed + 1)) return 1 - fi + } if [ "${got}" != "${expect}" ]; then echo "error: SFETCH_MINISIGN_PUB does not match the canonical consumer trust anchor" >&2 echo " expected (scripts/sfetch-minisign-anchor.pub): ${expect}" >&2 @@ -98,7 +106,7 @@ assert_operator_pub_is_canonical_anchor() { require_minisign_pub() { if [ -z "${SFETCH_MINISIGN_PUB}" ]; then - echo "error: SFETCH_MINISIGN_PUB not set, cannot verify minisign signatures" >&2 + echo "error: SFETCH_MINISIGN_PUB not set (required advisory; must match consumer anchor)" >&2 failed=$((failed + 1)) return 1 fi @@ -107,10 +115,24 @@ require_minisign_pub() { failed=$((failed + 1)) return 1 fi - assert_operator_pub_is_canonical_anchor || return 1 + assert_operator_pub_matches_canonical || return 1 return 0 } +# Always verify against the in-repo canonical anchor (not the operator path). +# SFETCH_MINISIGN_PUB is advisory-only after the match assert above. +verify_minisign_with_anchor() { + local label="$1" base="$2" + echo "🔍 [minisign] Verifying ${label} (canonical anchor)" + if minisign -V -p "${CANONICAL_ANCHOR_PUB}" -m "${base}"; then + echo "✅ ${label}.minisig verified against consumer trust anchor" + verified=$((verified + 1)) + else + echo "❌ ${label}.minisig verification FAILED against consumer trust anchor" + failed=$((failed + 1)) + fi +} + # Optional: skip when signature file is absent (manifests only). verify_minisign_optional() { local manifest="$1" @@ -124,15 +146,7 @@ verify_minisign_optional() { require_minisign_pub || return 1 require_minisign_tool || return 1 - - echo "🔍 [minisign] Verifying ${manifest}" - if minisign -V -p "${SFETCH_MINISIGN_PUB}" -m "${base}"; then - echo "✅ ${manifest}.minisig verified" - verified=$((verified + 1)) - else - echo "❌ ${manifest}.minisig verification FAILED" - failed=$((failed + 1)) - fi + verify_minisign_with_anchor "${manifest}" "${base}" } # Required: missing signature is a hard failure (installer only). @@ -158,15 +172,7 @@ verify_minisign_required() { require_minisign_pub || return 1 require_minisign_tool || return 1 - - echo "🔍 [minisign] Verifying ${target} (required)" - if minisign -V -p "${SFETCH_MINISIGN_PUB}" -m "${base}"; then - echo "✅ ${target}.minisig verified" - verified=$((verified + 1)) - else - echo "❌ ${target}.minisig verification FAILED" - failed=$((failed + 1)) - fi + verify_minisign_with_anchor "${target}" "${base}" } verify_pgp() { From e42bdc929825fac18d4f941ee94fb2f049d089fc Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 14:02:07 -0400 Subject: [PATCH 08/14] fix: compare semver components without integer overflow Replace Bash $((10#$n)) arithmetic in semver_cmp with length-then C-locale lexical decimal comparison so oversized components cannot wrap and pass the supported-range gate. Add huge-component harness negative. Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- scripts/bootstrap-sfetch-verified.sh | 46 +++++++++++++++++------ scripts/test-bootstrap-sfetch-verified.sh | 2 + 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index 6684adf..58b3350 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -71,9 +71,35 @@ is_exact_semver_tag() { [[ "$1" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] } +# Compare two non-negative integer decimal strings without machine arithmetic. +# Inputs must already be digits-only with no leading zeros (except "0"). +# Longer digit string is larger; equal length uses C-locale lexical order. +_semver_cmp_component() { + local x="$1" y="$2" + local lx=${#x} ly=${#y} + if [ "$lx" -lt "$ly" ]; then + echo -1 + return 0 + fi + if [ "$lx" -gt "$ly" ]; then + echo 1 + return 0 + fi + if [ "$x" = "$y" ]; then + echo 0 + return 0 + fi + # Equal width digit strings: C-locale lexical order == numeric order. + if [ "$(printf '%s\n%s\n' "$x" "$y" | LC_ALL=C sort | head -n1)" = "$x" ]; then + echo -1 + else + echo 1 + fi +} + # Compare two vX.Y.Z tags: echo -1 / 0 / 1 for ab. # Fail loudly (return non-zero, no stdout) on non-canonical components — -# never fall through to "equal" on arithmetic error. +# never use Bash integer arithmetic (overflow would fail open on range). semver_cmp() { local a="${1#v}" b="${2#v}" local a1 a2 a3 b1 b2 b3 @@ -93,20 +119,18 @@ semver_cmp() { ;; esac done - # Force base-10 integer comparison (avoid bash octal pitfalls). - if [ "$((10#$a1))" -ne "$((10#$b1))" ]; then - if [ "$((10#$a1))" -lt "$((10#$b1))" ]; then echo -1; else echo 1; fi - return 0 - fi - if [ "$((10#$a2))" -ne "$((10#$b2))" ]; then - if [ "$((10#$a2))" -lt "$((10#$b2))" ]; then echo -1; else echo 1; fi + local r + r="$(_semver_cmp_component "$a1" "$b1")" + if [ "$r" != "0" ]; then + echo "$r" return 0 fi - if [ "$((10#$a3))" -ne "$((10#$b3))" ]; then - if [ "$((10#$a3))" -lt "$((10#$b3))" ]; then echo -1; else echo 1; fi + r="$(_semver_cmp_component "$a2" "$b2")" + if [ "$r" != "0" ]; then + echo "$r" return 0 fi - echo 0 + _semver_cmp_component "$a3" "$b3" } semver_ge() { diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index f1980a2..25a72cb 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -50,6 +50,8 @@ if "$SCRIPT" --version v0.4.12 --dir /tmp 2>/dev/null; then fail "above max shou # F2: leading-zero components must not pass range/route selection if "$SCRIPT" --version v0.4.09 --dir /tmp 2>/dev/null; then fail "v0.4.09 leading zero should fail"; else pass "rejects v0.4.09 leading zero"; fi if "$SCRIPT" --version v0.04.11 --dir /tmp 2>/dev/null; then fail "v0.04.11 leading zero should fail"; else pass "rejects v0.04.11 leading zero"; fi +# Huge components must not wrap through Bash integer arithmetic into the supported range. +if "$SCRIPT" --version v18446744073709551616.4.10 --dir /tmp 2>/dev/null; then fail "huge component should fail before route/network"; else pass "rejects huge component before range accept"; fi # --- Helpers: patch temporary engines for fixtures (never production seams) --- WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-boot-test.XXXXXX")" From 869f86018bc4360942a9720105103c7d7e38be37 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 14:06:54 -0400 Subject: [PATCH 09/14] test: pin both semver overflow faces before route selection Harness asserts early reject for huge-major wrap-to-zero and huge-patch wrap-into-range (nonzero exit, no verify-route=, no fetch log, install dir absent). Document arithmetic-free comparator rationale; suppress SC2016 on intentional PowerShell $env: single-quoted command. Generated by Grok (https://x.ai) running Grok Build under supervision of [@3leapsdave](https://github.com/3leapsdave) Co-Authored-By: Grok Committer-of-Record: Dave Thompson [@3leapsdave] Role: devlead --- scripts/bootstrap-sfetch-verified.sh | 9 +++++++- scripts/test-bootstrap-sfetch-verified.sh | 27 +++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index 58b3350..2d51017 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -74,6 +74,11 @@ is_exact_semver_tag() { # Compare two non-negative integer decimal strings without machine arithmetic. # Inputs must already be digits-only with no leading zeros (except "0"). # Longer digit string is larger; equal length uses C-locale lexical order. +# +# Why no $((10#…)) / arithmetic: Bash integers wrap silently (e.g. 2^64 → 0, +# 2^64+11 → 11). That previously made huge components fail-open on the +# supported-range gate and even alias into a valid route (minisig). For +# canonical decimals, length-then-lexical is total and order-preserving. _semver_cmp_component() { local x="$1" y="$2" local lx=${#x} ly=${#y} @@ -99,7 +104,7 @@ _semver_cmp_component() { # Compare two vX.Y.Z tags: echo -1 / 0 / 1 for ab. # Fail loudly (return non-zero, no stdout) on non-canonical components — -# never use Bash integer arithmetic (overflow would fail open on range). +# never use Bash integer arithmetic (see _semver_cmp_component). semver_cmp() { local a="${1#v}" b="${2#v}" local a1 a2 a3 b1 b2 b3 @@ -390,6 +395,8 @@ ensure_minisign() { unzip -q -o "$zip" -d "${WORK}/minisign-extract" else # PowerShell Expand-Archive via env vars (no path interpolation into -Command). + # Single-quoted -Command intentionally keeps $env: for PowerShell, not Bash. + # shellcheck disable=SC2016 SFETCH_MINISIGN_ZIP_PATH="$zip" \ SFETCH_MINISIGN_EXTRACT_PATH="${WORK}/minisign-extract" \ powershell.exe -NoProfile -Command \ diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index 25a72cb..c80525b 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -50,8 +50,6 @@ if "$SCRIPT" --version v0.4.12 --dir /tmp 2>/dev/null; then fail "above max shou # F2: leading-zero components must not pass range/route selection if "$SCRIPT" --version v0.4.09 --dir /tmp 2>/dev/null; then fail "v0.4.09 leading zero should fail"; else pass "rejects v0.4.09 leading zero"; fi if "$SCRIPT" --version v0.04.11 --dir /tmp 2>/dev/null; then fail "v0.04.11 leading zero should fail"; else pass "rejects v0.04.11 leading zero"; fi -# Huge components must not wrap through Bash integer arithmetic into the supported range. -if "$SCRIPT" --version v18446744073709551616.4.10 --dir /tmp 2>/dev/null; then fail "huge component should fail before route/network"; else pass "rejects huge component before range accept"; fi # --- Helpers: patch temporary engines for fixtures (never production seams) --- WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/sft-boot-test.XXXXXX")" @@ -72,6 +70,31 @@ if "$SCRIPT" --version v0.4.09 --dir "$NO_SIDE" 2>/dev/null; then fail "v0.4.09 [ ! -e "$NO_SIDE" ] || fail "rejected version must not create install dir" pass "rejected version has no mkdir side effect" +# Huge components: both overflow faces must fail closed before route selection / network. +# Face 1: wrap-to-zero major (2^64). Face 2: wrap-into-range patch (2^64+11 → was 11 under Bash $((10#…))). +# A late 404 would also be nonzero — pin that rejection is early (no verify-route=, no fetch log, no install dir). +assert_early_version_reject() { + local ver="$1" label="$2" + local idir="$WORKDIR/early-reject-${label}" + local logf="$WORKDIR/early-reject-${label}.log" + local rc=0 + set +e + "$SCRIPT" --version "$ver" --dir "$idir" >"$logf" 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "${label}: expected nonzero exit for ${ver}" + if grep -q 'verify-route=' "$logf"; then + fail "${label}: must not emit verify-route= before range reject (${ver})" + fi + if grep -Eiq 'fetch attempt|failed to fetch|http(s)?://|curl |wget ' "$logf"; then + fail "${label}: must not perform network/fetch before range reject (${ver})" + fi + [ ! -e "$idir" ] || fail "${label}: install dir must remain absent for ${ver}" + pass "rejects ${label} early (${ver})" +} +assert_early_version_reject "v18446744073709551616.4.10" "huge-major-wrap-zero" +assert_early_version_reject "v0.4.18446744073709551627" "huge-patch-wrap-into-range" + PROD_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" # Build a temporary engine: optional test pubkey, fixed BASE_URL, ambient minisign. From 2e6c96c4049e6f14265d1c397c137fb9130b77c6 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 14:36:38 -0400 Subject: [PATCH 10/14] =?UTF-8?q?docs:=20entarch=20A/B=20=E2=80=94=20boots?= =?UTF-8?q?trap=20range=20release=20gate=20and=20verified=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add release-checklist steps to advance SFETCH_BOOTSTRAP_MAX/MINISIG_SINCE, assert MAX equals v(VERSION) from committed constants (not engine runtime), document action-SHA / sfetch-version pin coupling, and smoke-test post-release install via the verified engine instead of pipe-to-bash. Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- Makefile | 6 +- RELEASE_CHECKLIST.md | 17 ++- docs/cicd-usage-guide.md | 10 ++ scripts/assert-bootstrap-range-release.sh | 140 ++++++++++++++++++++++ scripts/bootstrap-sfetch-verified.sh | 10 +- 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100755 scripts/assert-bootstrap-range-release.sh diff --git a/Makefile b/Makefile index ed89fd8..ecd651e 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,7 @@ CORPUS_DEST ?= test-corpus .PHONY: release-verify-key release-verify-minisign-pubkey release-verify-keys release-verify-signatures release-verify .PHONY: release-clean bootstrap-script build-all gosec gosec-high update-scoop-manifest .PHONY: version-check version-set version-patch version-minor version-major -.PHONY: print-sfetch-version test-release-verify-checksums test-release-verify-signatures test-bootstrap-sfetch-verified +.PHONY: print-sfetch-version test-release-verify-checksums test-release-verify-signatures test-bootstrap-sfetch-verified test-bootstrap-range-release all: build @@ -213,6 +213,7 @@ precommit: ## Run pre-commit checks (goneat assess + Go tests + build) $(MAKE) test-release-verify-checksums $(MAKE) test-release-verify-signatures $(MAKE) test-bootstrap-sfetch-verified + $(MAKE) test-bootstrap-range-release @echo "[ok] Pre-commit checks passed" prepush: precommit ## Run pre-push checks (same as precommit + security) @@ -288,6 +289,9 @@ test-release-verify-signatures: ## Regression: required installer minisig + sign test-bootstrap-sfetch-verified: ## Regression: dual-route bootstrap rejects + fail-closed @./scripts/test-bootstrap-sfetch-verified.sh +test-bootstrap-range-release: ## Assert MAX==v(VERSION) and MINISIG_SINCE in range (committed constants) + @./scripts/assert-bootstrap-range-release.sh + release-notes: ## Copy release notes into dist/release @if [ -z "$(RELEASE_TAG)" ]; then echo "error: RELEASE_TAG not set" >&2; exit 1; fi @mkdir -p $(DIST_RELEASE) diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 3bc5979..2088c06 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -25,7 +25,13 @@ Why this matters: `make release-upload` now uploads the signed GitHub release as ## 1. Prepare & Tag - [ ] Ensure `main` is clean and `make precommit` passes -- [ ] Update `VERSION` file with new semver (e.g., `0.2.0`) +- [ ] Update `VERSION` file with new semver (e.g., `0.4.12`) +- [ ] **Advance verified-bootstrap range constants** (required every release — missing bump fails consumers, not sfetch CI, unless you run the assert): + - [ ] Set `SFETCH_BOOTSTRAP_MAX` in `scripts/bootstrap-sfetch-verified.sh` to `v$(cat VERSION)` (committed constant — **do not** derive at engine runtime from `VERSION` or the consumer tree) + - [ ] Review `SFETCH_MINISIG_SINCE` (first tag that publishes `install-sfetch.sh.minisig`). Wrong value mis-routes (3-step vs 5-step); both routes still verify, but route selection is part of the contract + - [ ] Update boundary tests in `scripts/test-bootstrap-sfetch-verified.sh` (e.g. assert `v(MAX+1)` is refused) + - [ ] Run `make test-bootstrap-range-release` (or full `make precommit`) — asserts `SFETCH_BOOTSTRAP_MAX == v$(cat VERSION)` and `MINISIG_SINCE` ∈ `[MIN, MAX]` + - [ ] After merge/tag, publish the new **action SHA** (and engine script digest if documenting Makefile consumers) — action SHA and `sfetch-version` pins are coupled in time; consumers advancing the pin must also advance the action ref when the range expands - [ ] Update `CHANGELOG.md` (move Unreleased to new version section) - [ ] Update `RELEASE_NOTES.md` - [ ] Create `docs/releases/vX.Y.Z.md` @@ -146,7 +152,14 @@ export SFETCH_GPG_HOMEDIR=/path/to/custom/gpg/homedir # optional, defaults to ## 3. Post-Release - [ ] Verify release: `gh release view v$(cat VERSION)` -- [ ] Test install script: `curl -sSfL .../install-sfetch.sh | bash -s -- --dry-run` +- [ ] Smoke-test install via the **verified engine** (not pipe-to-bash), against the just-published tag: + ```bash + TAG=v$(cat VERSION) + DEST=$(mktemp -d) + ./scripts/bootstrap-sfetch-verified.sh --version "$TAG" --dir "$DEST" --yes + "$DEST/sfetch" --version # must report $TAG + ``` + (After first publish of this tag's signatures; draft/unsigned releases are non-consumable.) - [ ] Verify binary version: `sfetch --version` shows correct version - [ ] Commit and push `../scoop-bucket` after confirming `bucket/sfetch.json` has the right version and hashes - [ ] Test on local Windows VM: `scoop bucket add 3leaps ` then `scoop install sfetch` diff --git a/docs/cicd-usage-guide.md b/docs/cicd-usage-guide.md index 3b36850..63f2f03 100644 --- a/docs/cicd-usage-guide.md +++ b/docs/cicd-usage-guide.md @@ -68,6 +68,16 @@ Never fall back from a failed `.minisig` attempt to checksums. `action.yml` into consumer repos (that re-creates the drift the action exists to eliminate). +**Pin coupling (action SHA ↔ `sfetch-version`):** each action commit freezes a +supported version range (`SFETCH_BOOTSTRAP_MIN`..`SFETCH_BOOTSTRAP_MAX` in the +engine at that SHA). A new sfetch release that raises the ceiling requires a +new action SHA that includes the advanced constants — bumping only +`sfetch-version` without moving the action ref fails closed with +`outside supported range`. That is intentional (fail-closed on unknown +versions). Plan consumer upgrades as **two coordinated pins**: action SHA and +exact tag. Do not read `VERSION` from a consumer workspace to “auto-advance” +the range at runtime; the range is a committed property of the pinned action. + ### Makefile / shell: shared engine (D2b) ```bash diff --git a/scripts/assert-bootstrap-range-release.sh b/scripts/assert-bootstrap-range-release.sh new file mode 100755 index 0000000..eb0dfb5 --- /dev/null +++ b/scripts/assert-bootstrap-range-release.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# assert-bootstrap-range-release.sh — release-gate coherence for bootstrap constants. +# +# Compares two *committed* values visible at the same SHA: +# VERSION file vs SFETCH_BOOTSTRAP_MAX in scripts/bootstrap-sfetch-verified.sh +# Also sanity-checks SFETCH_MINISIG_SINCE against the committed range. +# +# WHY THIS IS NOT IN THE ENGINE AT RUNTIME +# ---------------------------------------- +# Do not "simplify" by reading VERSION (or any consumer-tree file) inside +# bootstrap-sfetch-verified.sh during install. The action SHA must declare a +# *frozen* supported range; a runtime-derived ceiling floats the contract and +# reintroduces a workspace trust seam (engine must not depend on GITHUB_WORKSPACE +# or consumer checkout contents for verification policy). +# +# This assert belongs in sfetch's release/CI gate so a *missing* MAX bump fails +# here, not first in a consumer repo that tried sfetch-version: vN+1. +# +# Usage: assert-bootstrap-range-release.sh +# Exit 0 if coherent; non-zero otherwise. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +ENGINE="${ROOT}/scripts/bootstrap-sfetch-verified.sh" +VERSION_FILE="${ROOT}/VERSION" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +[ -f "$VERSION_FILE" ] || fail "VERSION file missing" +[ -f "$ENGINE" ] || fail "engine missing: $ENGINE" + +VER="$(tr -d '[:space:]' <"$VERSION_FILE")" +case "$VER" in + [0-9]*.[0-9]*.[0-9]*) + if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "VERSION must be exact MAJOR.MINOR.PATCH (got: $VER)" + fi + ;; + *) + fail "VERSION must be exact MAJOR.MINOR.PATCH (got: $VER)" + ;; +esac +TAG="v${VER}" + +# Extract committed constants from the engine (readonly assignments). +extract_const() { + local name="$1" + local line + line="$(grep -E "^readonly ${name}=" "$ENGINE" | head -n1 || true)" + [ -n "$line" ] || fail "engine constant ${name} not found" + # readonly NAME="value" + local val="${line#*=}" + val="${val#\"}" + val="${val%\"}" + val="${val#\'}" + val="${val%\'}" + printf '%s\n' "$val" +} + +MAX="$(extract_const SFETCH_BOOTSTRAP_MAX)" +MIN="$(extract_const SFETCH_BOOTSTRAP_MIN)" +SINCE="$(extract_const SFETCH_MINISIG_SINCE)" + +echo "bootstrap-range assert: VERSION=${VER} MAX=${MAX} MIN=${MIN} MINISIG_SINCE=${SINCE}" + +[ "$MAX" = "$TAG" ] || + fail "SFETCH_BOOTSTRAP_MAX (${MAX}) must equal v\$(cat VERSION) (${TAG}). Advance the constant (and boundary tests) before cutting this release — see RELEASE_CHECKLIST.md §1." + +# MINISIG_SINCE: route-selection constant. Wrong value is not a crypto hole but +# mis-routes consumers (5-step vs 3-step). Must be an exact tag within [MIN, MAX]. +case "$SINCE" in + v[0-9]*.[0-9]*.[0-9]*) + if ! [[ "${SINCE#v}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "SFETCH_MINISIG_SINCE must be exact vMAJOR.MINOR.PATCH (got: $SINCE)" + fi + ;; + *) + fail "SFETCH_MINISIG_SINCE must be exact vMAJOR.MINOR.PATCH (got: $SINCE)" + ;; +esac + +# Lightweight ordering using the engine's own helpers without installing. +# shellcheck source=scripts/bootstrap-sfetch-verified.sh +# We only need is_exact / semver helpers — source is heavy; inline minimal cmp. +semver_cmp_tag() { + # echo -1/0/1 for a vs b (vX.Y.Z) + local a="${1#v}" b="${2#v}" + local a1 a2 a3 b1 b2 b3 + IFS=. read -r a1 a2 a3 <<<"$a" + IFS=. read -r b1 b2 b3 <<<"$b" + # Use pure string length+lex for decimal components (same class as engine; + # components here are small committed constants, not untrusted input). + _cmp_one() { + local x="$1" y="$2" + local lx=${#x} ly=${#y} + if [ "$lx" -lt "$ly" ]; then + echo -1 + return + fi + if [ "$lx" -gt "$ly" ]; then + echo 1 + return + fi + if [[ "$x" < "$y" ]]; then + echo -1 + return + fi + if [[ "$x" > "$y" ]]; then + echo 1 + return + fi + echo 0 + } + local c + c="$(_cmp_one "$a1" "$b1")" + [ "$c" != 0 ] && { + echo "$c" + return + } + c="$(_cmp_one "$a2" "$b2")" + [ "$c" != 0 ] && { + echo "$c" + return + } + _cmp_one "$a3" "$b3" +} + +[ "$(semver_cmp_tag "$SINCE" "$MIN")" != "-1" ] || + fail "SFETCH_MINISIG_SINCE (${SINCE}) is below SFETCH_BOOTSTRAP_MIN (${MIN})" +[ "$(semver_cmp_tag "$SINCE" "$MAX")" != "1" ] || + fail "SFETCH_MINISIG_SINCE (${SINCE}) is above SFETCH_BOOTSTRAP_MAX (${MAX})" +[ "$(semver_cmp_tag "$MIN" "$MAX")" != "1" ] || + fail "SFETCH_BOOTSTRAP_MIN (${MIN}) is above SFETCH_BOOTSTRAP_MAX (${MAX})" + +echo "[ok] bootstrap range coherent: MAX=${MAX} == VERSION tag; MINISIG_SINCE=${SINCE} in [${MIN}..${MAX}]" diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index 2d51017..8625509 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -33,9 +33,17 @@ set -euo pipefail # Supported sfetch-version range for THIS script revision. Outside range ⇒ fail. # Action SHA identifies this contract; consumers pin the action/script, then a # version within the range it declares. +# +# Do NOT derive MAX/MIN/MINISIG_SINCE from VERSION or any path at runtime — +# that would float the pinned contract and reintroduce a workspace trust seam. +# Advance these constants at release-prep time (RELEASE_CHECKLIST.md §1); +# make test-bootstrap-range-release asserts MAX == v$(cat VERSION) using two +# committed values at the same SHA (never self-advancing at install time). readonly SFETCH_BOOTSTRAP_MIN="v0.4.9" readonly SFETCH_BOOTSTRAP_MAX="v0.4.11" -# First release that publishes install-sfetch.sh.minisig +# First release that publishes install-sfetch.sh.minisig (route selection). +# Keep this accurate: too low → minisig route without asset; too high → 5-step +# when 3-step exists. Both routes verify; neither silently falls back. readonly SFETCH_MINISIG_SINCE="v0.4.11" # Embedded trust anchor — must match scripts/sfetch-minisign-anchor.pub (SSOT), From 804ff2e41d9a388955969aa16c70c6970b5f5095 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 14:37:05 -0400 Subject: [PATCH 11/14] chore: shfmt bootstrap after range-constant comments Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- scripts/bootstrap-sfetch-verified.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index 8625509..cc6369f 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -408,7 +408,7 @@ ensure_minisign() { SFETCH_MINISIGN_ZIP_PATH="$zip" \ SFETCH_MINISIGN_EXTRACT_PATH="${WORK}/minisign-extract" \ powershell.exe -NoProfile -Command \ - 'Expand-Archive -LiteralPath $env:SFETCH_MINISIGN_ZIP_PATH -DestinationPath $env:SFETCH_MINISIGN_EXTRACT_PATH -Force' || + 'Expand-Archive -LiteralPath $env:SFETCH_MINISIGN_ZIP_PATH -DestinationPath $env:SFETCH_MINISIGN_EXTRACT_PATH -Force' || die "failed to extract minisign zip" fi local sub From 00847e9ee182cda2d9df9d5a05915409488ed1ca Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 15:12:29 -0400 Subject: [PATCH 12/14] fix: put pinned minisign on PATH for installer execution The bootstrap engine acquired minisign into a private temp dir for verification, then invoked install-sfetch.sh which requires minisign on PATH and failed on clean runners without ambient minisign. Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- scripts/bootstrap-sfetch-verified.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index cc6369f..2a0cd6b 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -593,8 +593,11 @@ case "$ROUTE" in ;; esac -# Execute only after verification (never pipe curl | bash) +# Execute only after verification (never pipe curl | bash). +# install-sfetch.sh requires minisign on PATH for its own SHA256SUMS verify; +# export the same pinned binary we already acquired (temp dir is not on PATH). log "executing verified installer for ${VERSION} → ${INSTALL_DIR}" +export PATH="$(dirname "${MINISIGN_BIN}"):${PATH}" # shellcheck disable=SC2086 bash "$SCRIPT" --tag "$VERSION" --dir "$INSTALL_DIR" --yes --require-minisign From 7be01f52a90cea2b7c2281752e806af75abe06c5 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 15:12:56 -0400 Subject: [PATCH 13/14] chore: satisfy SC2155 on PATH export for pinned minisign Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- scripts/bootstrap-sfetch-verified.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index 2a0cd6b..5def5d2 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -597,7 +597,8 @@ esac # install-sfetch.sh requires minisign on PATH for its own SHA256SUMS verify; # export the same pinned binary we already acquired (temp dir is not on PATH). log "executing verified installer for ${VERSION} → ${INSTALL_DIR}" -export PATH="$(dirname "${MINISIGN_BIN}"):${PATH}" +_minisign_dir="$(dirname "${MINISIGN_BIN}")" +export PATH="${_minisign_dir}:${PATH}" # shellcheck disable=SC2086 bash "$SCRIPT" --tag "$VERSION" --dir "$INSTALL_DIR" --yes --require-minisign From 2cc6643e2a9c44ee3d32d3a06e8259371e5df2ae Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Fri, 31 Jul 2026 15:38:59 -0400 Subject: [PATCH 14/14] fix(ci): assert version via stderr; fixtures use pinned minisign sfetch --version writes to stderr, so the setup-sfetch PATH assert grepped an empty tee file. Fixture engines no longer inject ambient minisign (CI distro packages are often 0.11); they use the engine's pinned 0.12 download. Generated by Grok 4.5 (https://x.ai) running Grok Build (https://x.ai) under supervision of @3leapsdave Co-Authored-By: Grok 4.5 Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- .github/workflows/ci.yml | 3 ++- scripts/test-bootstrap-sfetch-verified.sh | 22 ++++------------------ 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 986849e..5873d8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,8 @@ jobs: run: | set -euo pipefail command -v sfetch - sfetch --version | tee /tmp/sfetch-ver.txt + # sfetch --version writes to stderr (not stdout) + sfetch --version 2>&1 | tee /tmp/sfetch-ver.txt grep -E '0\.4\.10' /tmp/sfetch-ver.txt - name: Fail-closed optional tool (goneat not requested) diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index c80525b..b1c650d 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -97,8 +97,10 @@ assert_early_version_reject "v0.4.18446744073709551627" "huge-patch-wrap-into-ra PROD_PUBKEY="RWTAoUJ007VE3h8tbHlBCyk2+y0nn7kyA4QP34LTzdtk8M6A2sryQtZC" -# Build a temporary engine: optional test pubkey, fixed BASE_URL, ambient minisign. -# Ambient minisign is injected only into the temporary copy (not production). +# Build a temporary engine: optional test pubkey + fixed BASE_URL for sfetch assets. +# Minisign always comes from the engine's pinned 0.12 download (not ambient PATH). +# CI runners often ship distro minisign 0.11; using ambient would fail the 0.12 assert +# and is not how production runs. make_fixture_engine() { local dest="$1" base_url="$2" pubkey="${3-}" local src="$SCRIPT" @@ -108,22 +110,6 @@ make_fixture_engine() { sed -i.bak \ 's|BASE_URL="https://github.com/${REPO}/releases/download"|BASE_URL="'"${base_url}"'"|' \ "$dest" - # Force ambient minisign at start of ensure_minisign (fixture-only). - # Insert after "ensure_minisign() {" - awk ' - /^ensure_minisign\(\) \{$/ { - print - print " # FIXTURE: ambient minisign (temporary harness copy only)" - print " command -v minisign >/dev/null 2>&1 || die \"fixture requires ambient minisign\"" - print " MINISIGN_BIN=\"$(command -v minisign)\"" - print " assert_minisign_version" - print " log \"fixture ambient minisign: ${MINISIGN_BIN}\"" - print " return 0" - next - } - { print } - ' "$dest" >"${dest}.new" - mv "${dest}.new" "$dest" if [ -n "$pubkey" ]; then sed -i.bak "s|${PROD_PUBKEY}|${pubkey}|g" "$dest" grep -q "$pubkey" "$dest" || fail "patched engine missing test pubkey"