diff --git a/.changes/authenticate-ci-tool-downloads-6253.md b/.changes/authenticate-ci-tool-downloads-6253.md new file mode 100644 index 00000000..c9003a18 --- /dev/null +++ b/.changes/authenticate-ci-tool-downloads-6253.md @@ -0,0 +1,5 @@ +--- +"rscrypto" = "patch" +--- + +Authenticate CI tool downloads and package-manager installs. diff --git a/.config/ci-tool-archives.tsv b/.config/ci-tool-archives.tsv new file mode 100644 index 00000000..f52eafdc --- /dev/null +++ b/.config/ci-tool-archives.tsv @@ -0,0 +1,7 @@ +# tool version os architecture filename url sha256 +wasmtime v46.0.1 linux x86_64 wasmtime-v46.0.1-x86_64-linux.tar.xz https://github.com/bytecodealliance/wasmtime/releases/download/v46.0.1/wasmtime-v46.0.1-x86_64-linux.tar.xz 9ae0b17ea298bcc52277a8208d6ab7fae8e1a89579672f9d82f9d86c116edb62 +wasmtime v46.0.1 linux aarch64 wasmtime-v46.0.1-aarch64-linux.tar.xz https://github.com/bytecodealliance/wasmtime/releases/download/v46.0.1/wasmtime-v46.0.1-aarch64-linux.tar.xz 071c4def2a08f0ebc95c52dfd4f2886eb697ba495804217cf76e13b09d70a1be +wasmtime v46.0.1 macos x86_64 wasmtime-v46.0.1-x86_64-macos.tar.xz https://github.com/bytecodealliance/wasmtime/releases/download/v46.0.1/wasmtime-v46.0.1-x86_64-macos.tar.xz 0513db67e7089c7e5f743a01427782bc4def83854222f4bc9b1d75f0b925240b +wasmtime v46.0.1 macos aarch64 wasmtime-v46.0.1-aarch64-macos.tar.xz https://github.com/bytecodealliance/wasmtime/releases/download/v46.0.1/wasmtime-v46.0.1-aarch64-macos.tar.xz acee50be70dbe90b0ab2ac7db1321fc44715153a1b1cc58291c97b6d7cffc558 +zig 0.17.0-dev.1282+c0f9b51d8 linux x86_64 zig-x86_64-linux-0.17.0-dev.1282+c0f9b51d8.tar.xz https://ziglang.org/builds/zig-x86_64-linux-0.17.0-dev.1282+c0f9b51d8.tar.xz 6d81dec0152f6f11f8a12a84a73535b65070fe01b14a1ce75423870d65b3f270 +codecov v11.3.1 linux x86_64 codecov https://cli.codecov.io/v11.3.1/linux/codecov ca1d64196d2d34771084afe76ea657d581bf628e31d993ff8e52ea09cc88a56d diff --git a/.github/actions/scorecard/action.yaml b/.github/actions/scorecard/action.yaml new file mode 100644 index 00000000..ce8dbb4c --- /dev/null +++ b/.github/actions/scorecard/action.yaml @@ -0,0 +1,34 @@ +name: Run OpenSSF Scorecard +description: Run the exact OpenSSF Scorecard container authenticated by its OCI digest. + +inputs: + results_file: + description: Path to the result file. + required: true + results_format: + description: Result format (json or sarif). + required: true + repo_token: + description: GitHub token with read access. + required: false + default: ${{ github.token }} + publish_results: + description: Publish results to the Scorecard API. + required: false + default: "false" + file_mode: + description: Method used to fetch repository files. + required: false + default: archive + internal_publish_base_url: + description: Scorecard publication endpoint. + required: false + default: https://api.scorecard.dev + internal_default_token: + description: Default token used by the Scorecard image. + required: false + default: ${{ github.token }} + +runs: + using: docker + image: docker://ghcr.io/ossf/scorecard-action@sha256:2dd6a6d60100f78ef24e14a47941d0087a524b4d3642041558239b1c6097c941 # v2.4.3 diff --git a/.github/actions/setup-toolchain/action.yaml b/.github/actions/setup-toolchain/action.yaml index 21a7d6a3..9f3f968c 100644 --- a/.github/actions/setup-toolchain/action.yaml +++ b/.github/actions/setup-toolchain/action.yaml @@ -25,15 +25,9 @@ runs: echo "toolchain=$TOOLCHAIN" >> "$GITHUB_OUTPUT" echo "Using toolchain: $TOOLCHAIN" - - name: Install Rust Toolchain (with components) - if: inputs.components != '' - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: ${{ steps.read-toolchain.outputs.toolchain }} - components: ${{ inputs.components }} - - - name: Install Rust Toolchain (no components) - if: inputs.components == '' - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: ${{ steps.read-toolchain.outputs.toolchain }} + - name: Install Rust Toolchain + shell: bash + env: + TOOLCHAIN: ${{ steps.read-toolchain.outputs.toolchain }} + TOOLCHAIN_COMPONENTS: ${{ inputs.components }} + run: scripts/ci/setup-toolchain.sh "$TOOLCHAIN" "$TOOLCHAIN_COMPONENTS" diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index ba7872a5..a9f05709 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -28,11 +28,6 @@ inputs: runs: using: composite steps: - - name: Detect Host Architecture - id: host - shell: bash - run: echo "uname_m=$(uname -m)" >> "$GITHUB_OUTPUT" - # runs-on.com S3-backed cache intercept. No-op on non-runs-on pools. - name: Setup MagicCache if: inputs.enable-magic-cache == 'true' @@ -43,27 +38,10 @@ runs: with: components: ${{ inputs.toolchain-components }} - # Cargo tools cache — mode in the key so mode swaps don't contaminate a shared slot. - - name: Cache Cargo Tools - if: inputs.tools-mode != 'none' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cargo/bin - key: cargo-tools-${{ runner.os }}-${{ steps.host.outputs.uname_m }}-${{ inputs.tools-mode }}-v11 - - - name: Cache BINSEC OPAM Switch - if: inputs.tools-mode == 'ct-linux' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.opam - key: binsec-opam-${{ runner.os }}-${{ steps.host.outputs.uname_m }}-v2 - - name: Install Cargo Tools if: inputs.tools-mode != 'none' shell: bash env: - CARGO_RAIL_VERSION: "0.18.0" - CARGO_SEMVER_CHECKS_VERSION: "0.48.0" TOOLS_MODE: ${{ inputs.tools-mode }} run: scripts/ci/install-tools.sh "$TOOLS_MODE" @@ -73,6 +51,7 @@ runs: with: shared-key: "rscrypto-v1-${{ inputs.cache-key }}" cache-directories: ${{ inputs.tools-mode == 'rail' && 'target/cargo-rail/cache' || '' }} + cache-bin: false cache-on-failure: true # Populate cache on main, manual dispatch, and PRs (so slow platforms # like IBM/RISC-V warm their cache before merge). diff --git a/.github/workflows/_ci-suite.yaml b/.github/workflows/_ci-suite.yaml index 788b2c8f..65997b27 100644 --- a/.github/workflows/_ci-suite.yaml +++ b/.github/workflows/_ci-suite.yaml @@ -158,7 +158,7 @@ jobs: uses: ./.github/workflows/_rust-job.yaml with: runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci - timeout_minutes: 15 + timeout_minutes: 45 cache_key: ${{ inputs.cache_key_prefix }}-supply-chain tools_mode: supply-chain enable_magic_cache: true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8b54f961..4111c30d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,28 +33,32 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 1 + fetch-depth: 0 persist-credentials: false - - name: Build Plan (PR) + - name: Setup Planner (PR) if: github.event_name == 'pull_request' - id: rail - uses: loadingalias/cargo-rail-action@90f7f739028887508c6e8d8b7cc510dc612eb653 # v5.1.0 + id: rail_setup + uses: ./.github/actions/setup with: - version: "0.18.0" + cache-key: rail-plan + tools-mode: rail + toolchain-components: "" + enable-magic-cache: false + enable-rust-cache: true - name: Resolve Build Plan if: always() id: resolve env: - RAIL_PLAN_STEP_OUTCOME: ${{ steps.rail.outcome }} - RAIL_SCOPE_JSON: ${{ steps.rail.outputs.scope-json }} + RAIL_PLAN_STEP_OUTCOME: ${{ steps.rail_setup.outcome }} + RAIL_SINCE: origin/${{ github.base_ref }} run: scripts/ci/resolve-rail-plan.sh - name: Check Release Intent Coverage if: github.event_name == 'pull_request' env: - RAIL_BASE_REF: ${{ steps.rail.outputs.base-ref }} + RAIL_BASE_REF: origin/${{ github.base_ref }} run: cargo rail change check --since "$RAIL_BASE_REF" --required dependency-smoke: @@ -63,7 +67,7 @@ jobs: uses: ./.github/workflows/_rust-job.yaml with: runner: ubuntu-latest - timeout_minutes: 20 + timeout_minutes: 45 cache_key: dependabot-smoke tools_mode: supply-chain toolchain_components: "clippy, rustfmt" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7c6b38b9..fcfa8ae3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,7 +21,7 @@ jobs: preflight: name: Preflight runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 90 outputs: crate_name: ${{ steps.preflight.outputs.crate_name }} crate_sha256: ${{ steps.preflight.outputs.crate_sha256 }} diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml index c72699c7..42ffaa8f 100644 --- a/.github/workflows/scorecard.yaml +++ b/.github/workflows/scorecard.yaml @@ -28,7 +28,7 @@ jobs: persist-credentials: false - name: Run Scorecard - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ./.github/actions/scorecard with: results_file: scorecard.sarif results_format: sarif diff --git a/.github/workflows/weekly.yaml b/.github/workflows/weekly.yaml index a26f365c..7eccc921 100644 --- a/.github/workflows/weekly.yaml +++ b/.github/workflows/weekly.yaml @@ -199,9 +199,14 @@ jobs: - name: Total Coverage (nextest + fuzz corpus replay) run: just test-coverage + - name: Install authenticated Codecov CLI + id: codecov + run: scripts/ci/install-codecov.sh + - name: Upload to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: + binary: ${{ steps.codecov.outputs.binary }} token: ${{ secrets.CODECOV_TOKEN }} files: coverage/total.lcov flags: total diff --git a/justfile b/justfile index b3204127..2b1cf3ab 100644 --- a/justfile +++ b/justfile @@ -240,6 +240,7 @@ update-check: check-actions: @scripts/ci/check-action-pins.sh @scripts/ci/check-action-pins-test.sh + @scripts/ci/tool-integrity-test.sh @scripts/ci/dependabot-smoke-test.sh @scripts/ci/check-ci-ownership.sh @scripts/ci/check-ci-ownership-test.sh diff --git a/scripts/README.md b/scripts/README.md index 52584f69..969e7e41 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -24,6 +24,7 @@ its caller. | `bench/bench.sh` | `just bench`, `just bench-quick` | | `ci/check-action-pins.sh` | `just check-actions`, `ci/ci-check.sh`, `ci/dependabot-smoke.sh` | | `ci/check-action-pins-test.sh` | `just check-actions`, `ci/dependabot-smoke.sh` | +| `ci/tool-integrity-test.sh` | `just check-actions` | | `ci/dependabot-smoke-test.sh` | `just check-actions` | | `ci/check-ci-ownership.sh` | `just check-actions`, `ci/check-ci-ownership-test.sh` | | `ci/check-ci-ownership-test.sh`| `just check-actions` | @@ -77,6 +78,8 @@ claim in [`docs/secret-lifecycle.md`](../docs/secret-lifecycle.md). | Script | Callers | |--------|---------| | `ci/install-tools.sh` | `.github/actions/setup/action.yaml` | +| `ci/install-codecov.sh` | `weekly.yaml` | +| `ci/setup-toolchain.sh` | `.github/actions/setup-toolchain/action.yaml` | | `ci/run-rust-job.sh` | `.github/workflows/_rust-job.yaml` | | `ci/resolve-rail-plan.sh` | `.github/workflows/ci.yaml` | | `ci/dependabot-smoke.sh` | `ci/run-rust-job.sh` | @@ -95,6 +98,27 @@ claim in [`docs/secret-lifecycle.md`](../docs/secret-lifecycle.md). | `lib/targets.sh` | `scripts/check/check-all.sh`, `scripts/check/check-linux.sh`, `scripts/check/check-ibm.sh` | | `lib/target-matrix.sh` | `scripts/lib/targets.sh`, `_ci-suite.yaml` (target-matrix job) | | `lib/toolchain.sh` | `.github/actions/setup-toolchain/action.yaml` | +| `lib/ci-tool-integrity.sh` | `ci/cross-targets.sh`, `ci/install-codecov.sh`, `ci/nostd-wasm-suite.sh` | + +## CI tool integrity + +Direct executable downloads are declared in +`.config/ci-tool-archives.tsv`. Each supported host has one exact version, +filename, HTTPS URL, and repository-owned SHA-256; the shared verifier checks +the digest before extraction, installation, or execution. OCI tools use an +image digest in their local action definition. + +Package-manager tools install into a fresh runner-temporary root; CI never +restores Cargo binaries, Cargo install metadata, Go module state, or OPAM +switches from a cache. Cargo installs exact crates from crates.io and +authenticates crate contents against registry checksums. Go installs an exact +module through the public checksum database. Ubuntu 24.04 APT packages are +exact-versioned and authenticated by signed repository metadata. OPAM uses +exact packages from a repository pinned to a full Git commit and verifies +package source hashes from that immutable metadata. Rustup receives only the +exact toolchain declared in `rust-toolchain.toml`; runner images must provide +rustup, which verifies component downloads against the exact distribution +manifest, because network bootstrap installers are rejected. ## Results layout diff --git a/scripts/check/check.sh b/scripts/check/check.sh index c1d09c33..7f154a8a 100755 --- a/scripts/check/check.sh +++ b/scripts/check/check.sh @@ -5,9 +5,6 @@ set -euo pipefail # optional deny/audit, doc # Usage: check.sh [--feature-matrix] [--all] [crate1 crate2 ...] -# Prefer cargo-installed tools over any preinstalled runner tools. -export PATH="$HOME/.cargo/bin:$PATH" - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=../lib/common.sh source "$SCRIPT_DIR/../lib/common.sh" diff --git a/scripts/ci/check-ci-ownership-test.sh b/scripts/ci/check-ci-ownership-test.sh index 642571bc..a9c5d863 100755 --- a/scripts/ci/check-ci-ownership-test.sh +++ b/scripts/ci/check-ci-ownership-test.sh @@ -9,14 +9,19 @@ trap 'rm -rf "$TMP_ROOT"' EXIT make_fixture() { local fixture=$1 - mkdir -p "$fixture/.github" "$fixture/.config" "$fixture/scripts/check" "$fixture/scripts/test" + mkdir -p "$fixture/.github" "$fixture/.config" "$fixture/scripts/check" "$fixture/scripts/lib" "$fixture/scripts/test" cp -R "$REPO_ROOT/.github/workflows" "$fixture/.github/workflows" + cp -R "$REPO_ROOT/.github/actions" "$fixture/.github/actions" cp -R "$REPO_ROOT/.github/rulesets" "$fixture/.github/rulesets" cp -R "$REPO_ROOT/.github/repository-settings" "$fixture/.github/repository-settings" cp "$REPO_ROOT/.github/dependabot.yaml" "$fixture/.github/dependabot.yaml" cp "$REPO_ROOT/.config/target-matrix.json" "$fixture/.config/target-matrix.json" + cp "$REPO_ROOT/.config/ci-tool-archives.tsv" "$fixture/.config/ci-tool-archives.tsv" cp -R "$REPO_ROOT/scripts/ci" "$fixture/scripts/ci" - cp "$REPO_ROOT/scripts/check/check-all.sh" "$REPO_ROOT/scripts/check/check-feature-matrix.sh" "$fixture/scripts/check/" + cp "$REPO_ROOT/scripts/lib/ci-tool-integrity.sh" "$REPO_ROOT/scripts/lib/common.sh" \ + "$fixture/scripts/lib/" + cp "$REPO_ROOT/scripts/check/check-all.sh" "$REPO_ROOT/scripts/check/check-feature-matrix.sh" \ + "$REPO_ROOT/scripts/check/check.sh" "$fixture/scripts/check/" cp "$REPO_ROOT/scripts/test/test-feature-matrix.sh" "$fixture/scripts/test/" } @@ -33,6 +38,85 @@ baseline="$TMP_ROOT/baseline" make_fixture "$baseline" "$CHECKER" --root "$baseline" >/dev/null +invalid_tool_digest="$TMP_ROOT/invalid-tool-digest" +make_fixture "$invalid_tool_digest" +sed -i.bak 's/ca1d64196d2d34771084afe76ea657d581bf628e31d993ff8e52ea09cc88a56d/not-a-digest/' \ + "$invalid_tool_digest/.config/ci-tool-archives.tsv" +rm -f "$invalid_tool_digest/.config/ci-tool-archives.tsv.bak" +expect_failure "$invalid_tool_digest" "direct tool digest is malformed" + +mutable_tool_url="$TMP_ROOT/mutable-tool-url" +make_fixture "$mutable_tool_url" +sed -i.bak 's#/download/v46\.0\.1/#/download/Latest/#' \ + "$mutable_tool_url/.config/ci-tool-archives.tsv" +rm -f "$mutable_tool_url/.config/ci-tool-archives.tsv.bak" +expect_failure "$mutable_tool_url" "direct tool URL resolves a mutable release" + +unexpected_tool_filename="$TMP_ROOT/unexpected-tool-filename" +make_fixture "$unexpected_tool_filename" +awk -F '\t' -v OFS='\t' '$1 == "codecov" { $5 = "codecov-substitute" } { print }' \ + "$unexpected_tool_filename/.config/ci-tool-archives.tsv" \ + >"$unexpected_tool_filename/.config/ci-tool-archives.tsv.tmp" +mv "$unexpected_tool_filename/.config/ci-tool-archives.tsv.tmp" \ + "$unexpected_tool_filename/.config/ci-tool-archives.tsv" +expect_failure "$unexpected_tool_filename" "direct tool URL and filename disagree" + +unauthenticated_cargo_installer="$TMP_ROOT/unauthenticated-cargo-installer" +make_fixture "$unauthenticated_cargo_installer" +printf '\ncargo binstall cargo-nextest\n' \ + >>"$unauthenticated_cargo_installer/scripts/ci/install-tools.sh" +expect_failure "$unauthenticated_cargo_installer" "Cargo-binstall bypasses package integrity" + +poisonable_tool_cache="$TMP_ROOT/poisonable-tool-cache" +make_fixture "$poisonable_tool_cache" +yq eval '.runs.steps += [{"name": "Restore poisonable tools", "uses": "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", "with": {"path": "~/.cargo/bin", "key": "known"}}]' -i \ + "$poisonable_tool_cache/.github/actions/setup/action.yaml" +expect_failure "$poisonable_tool_cache" "CI tool executables can be restored from a poisonable cache" + +rust_cache_binaries="$TMP_ROOT/rust-cache-binaries" +make_fixture "$rust_cache_binaries" +yq eval '(.runs.steps[] | select(.name == "Setup Rust Cache") | .with."cache-bin") = true' -i \ + "$rust_cache_binaries/.github/actions/setup/action.yaml" +expect_failure "$rust_cache_binaries" "the Rust build cache can restore Cargo tool executables" + +unauthenticated_rustup="$TMP_ROOT/unauthenticated-rustup" +make_fixture "$unauthenticated_rustup" +printf '\n - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9\n' \ + >>"$unauthenticated_rustup/.github/actions/setup-toolchain/action.yaml" +expect_failure "$unauthenticated_rustup" "toolchain setup can run a network bootstrap installer" + +unpinned_scorecard="$TMP_ROOT/unpinned-scorecard" +make_fixture "$unpinned_scorecard" +sed -i.bak 's#@sha256:[0-9a-f]*#:v2.4.3#' \ + "$unpinned_scorecard/.github/actions/scorecard/action.yaml" +rm -f "$unpinned_scorecard/.github/actions/scorecard/action.yaml.bak" +expect_failure "$unpinned_scorecard" "Scorecard container uses a mutable tag" + +floating_codecov="$TMP_ROOT/floating-codecov" +make_fixture "$floating_codecov" +sed -i.bak '/binary:.*steps\.codecov\.outputs\.binary/d' \ + "$floating_codecov/.github/workflows/weekly.yaml" +rm -f "$floating_codecov/.github/workflows/weekly.yaml.bak" +expect_failure "$floating_codecov" "Codecov action can download its floating default CLI" + +unowned_download="$TMP_ROOT/unowned-download" +make_fixture "$unowned_download" +printf '\ncurl --output /tmp/tool https://example.invalid/tool\n' \ + >>"$unowned_download/scripts/ci/run-rust-job.sh" +expect_failure "$unowned_download" "a direct download exists outside the integrity owner" + +unowned_package_install="$TMP_ROOT/unowned-package-install" +make_fixture "$unowned_package_install" +printf '\ncargo install ripgrep\n' \ + >>"$unowned_package_install/scripts/ci/run-rust-job.sh" +expect_failure "$unowned_package_install" "a package-manager install exists outside the integrity owner" + +unowned_test_download="$TMP_ROOT/unowned-test-download" +make_fixture "$unowned_test_download" +printf '#!/usr/bin/env bash\ncurl --output /tmp/tool https://example.invalid/tool\n' \ + >"$unowned_test_download/scripts/ci/unreviewed-test.sh" +expect_failure "$unowned_test_download" "a test script bypasses downloader ownership" + missing_action_updates="$TMP_ROOT/missing-action-updates" make_fixture "$missing_action_updates" yq eval 'del(.updates[] | select(."package-ecosystem" == "github-actions"))' -i \ diff --git a/scripts/ci/check-ci-ownership.sh b/scripts/ci/check-ci-ownership.sh index 44bb0055..8ca636fa 100755 --- a/scripts/ci/check-ci-ownership.sh +++ b/scripts/ci/check-ci-ownership.sh @@ -17,16 +17,28 @@ if [[ -z "$ROOT" ]]; then fi WORKFLOWS="$ROOT/.github/workflows" +ACTIONS="$ROOT/.github/actions" CI="$WORKFLOWS/ci.yaml" SUITE="$WORKFLOWS/_ci-suite.yaml" RUST_JOB="$WORKFLOWS/_rust-job.yaml" WEEKLY="$WORKFLOWS/weekly.yaml" +SCORECARD="$WORKFLOWS/scorecard.yaml" RISCV="$WORKFLOWS/riscv.yaml" RELEASE="$WORKFLOWS/release.yaml" RSA="$WORKFLOWS/rsa.yaml" +SETUP_ACTION="$ACTIONS/setup/action.yaml" +TOOLCHAIN_ACTION="$ACTIONS/setup-toolchain/action.yaml" +SCORECARD_ACTION="$ACTIONS/scorecard/action.yaml" MANIFEST="$ROOT/.config/target-matrix.json" +TOOL_ARCHIVES="$ROOT/.config/ci-tool-archives.tsv" CROSS_SCRIPT="$ROOT/scripts/ci/cross-targets.sh" +NOSTD_WASM="$ROOT/scripts/ci/nostd-wasm-suite.sh" +INSTALL_TOOLS="$ROOT/scripts/ci/install-tools.sh" +INSTALL_CODECOV="$ROOT/scripts/ci/install-codecov.sh" +SETUP_TOOLCHAIN="$ROOT/scripts/ci/setup-toolchain.sh" +TOOL_INTEGRITY="$ROOT/scripts/lib/ci-tool-integrity.sh" COMPILE_MATRIX="$ROOT/scripts/check/check-feature-matrix.sh" +HOST_CHECK="$ROOT/scripts/check/check.sh" EXECUTABLE_MATRIX="$ROOT/scripts/test/test-feature-matrix.sh" CHECK_ALL="$ROOT/scripts/check/check-all.sh" CI_CHECK="$ROOT/scripts/ci/ci-check.sh" @@ -91,12 +103,23 @@ require_file "$CI" require_file "$SUITE" require_file "$RUST_JOB" require_file "$WEEKLY" +require_file "$SCORECARD" require_file "$RISCV" require_file "$RELEASE" require_file "$RSA" +require_file "$SETUP_ACTION" +require_file "$TOOLCHAIN_ACTION" +require_file "$SCORECARD_ACTION" require_file "$MANIFEST" +require_file "$TOOL_ARCHIVES" require_file "$CROSS_SCRIPT" +require_file "$NOSTD_WASM" +require_file "$INSTALL_TOOLS" +require_file "$INSTALL_CODECOV" +require_file "$SETUP_TOOLCHAIN" +require_file "$TOOL_INTEGRITY" require_file "$COMPILE_MATRIX" +require_file "$HOST_CHECK" require_file "$EXECUTABLE_MATRIX" require_file "$CHECK_ALL" require_file "$CI_CHECK" @@ -194,6 +217,115 @@ if grep -En '(^|[[:space:]])eval[[:space:]]|(^|[[:space:]])(bash|sh)[[:space:]]+ fail "the Rust job dispatcher must not invoke a dynamic shell interpreter" fi +bash -eu -o pipefail -c 'source "$1"; ci_tool_validate_manifest' _ "$TOOL_INTEGRITY" \ + || fail "direct CI tool archive manifest is invalid" + +if grep -ERn 'uses:[[:space:]]+(dtolnay/rust-toolchain|loadingalias/cargo-rail-action|ossf/scorecard-action)@' \ + "$WORKFLOWS" "$ACTIONS" >/dev/null; then + fail "CI must not delegate installation to an action with an unauthenticated executable fallback" +fi +if grep -En 'cargo[[:space:]]+binstall|cargo-binstall|releases/latest|/latest/|curl[^|]*\|[[:space:]]*(bash|sh)' \ + "$INSTALL_TOOLS" "$SETUP_TOOLCHAIN" "$NOSTD_WASM" "$CROSS_SCRIPT" "$INSTALL_CODECOV" >/dev/null; then + fail "CI tool installers must reject Cargo-binstall, mutable URLs, and piped network installers" +fi + +download_files=$( + { + grep -ERl --include='*.sh' --include='*.yaml' --include='*.yml' \ + '(^|[[:space:]])(curl|wget|aria2c)([[:space:]]|$)|Invoke-(WebRequest|RestMethod)|Start-BitsTransfer|gh[[:space:]]+release[[:space:]]+download' \ + "$ROOT/scripts" "$WORKFLOWS" "$ACTIONS" 2>/dev/null || true + } | while IFS= read -r file; do + case "$file" in + "$ROOT/scripts/ci/check-ci-ownership-test.sh" \ + | "$ROOT/scripts/ci/tool-integrity-test.sh" \ + | "$ROOT/scripts/ci/check-ci-ownership.sh") continue ;; + esac + printf '%s\n' "${file#"$ROOT"/}" + done | sort +) +expected_download_files=$(printf '%s\n' \ + '.github/workflows/release.yaml' \ + 'scripts/ci/check-action-pins.sh' \ + 'scripts/lib/ci-tool-integrity.sh') +[[ "$download_files" == "$expected_download_files" ]] \ + || fail "network downloads exist outside the tool verifier or reviewed non-tool paths" + +installer_files=$( + { + grep -ERl --include='*.sh' --include='*.yaml' --include='*.yml' \ + 'cargo[[:space:]]+(binstall|install)|go[[:space:]]+install|apt(-get)?[[:space:]]+install|opam[[:space:]]+(init|install|reinstall|switch[[:space:]]+create)|rustup[[:space:]]+(toolchain[[:space:]]+install|component[[:space:]]+add|target[[:space:]]+add)|install_args=\(toolchain[[:space:]]+install|pipx?[[:space:]]+install|uv[[:space:]]+tool[[:space:]]+install|npm[[:space:]]+(install|ci)|pnpm[[:space:]]+install|yarn[[:space:]]+install|brew[[:space:]]+install' \ + "$ROOT/scripts/ci" "$ROOT/scripts/lib" "$WORKFLOWS" "$ACTIONS" 2>/dev/null || true + } | while IFS= read -r file; do + case "$file" in + "$ROOT/scripts/ci/check-ci-ownership-test.sh" \ + | "$ROOT/scripts/ci/tool-integrity-test.sh" \ + | "$ROOT/scripts/ci/check-ci-ownership.sh") continue ;; + esac + printf '%s\n' "${file#"$ROOT"/}" + done | sort +) +expected_installer_files=$(printf '%s\n' \ + 'scripts/ci/install-tools.sh' \ + 'scripts/ci/nostd-wasm-suite.sh' \ + 'scripts/ci/setup-toolchain.sh' \ + 'scripts/lib/common.sh') +[[ "$installer_files" == "$expected_installer_files" ]] \ + || fail "package-manager installs exist outside the reviewed integrity boundaries" + +[[ $(yq eval '.jobs."rail-plan".steps[] | select(.id == "rail_setup") | .uses' "$CI") \ + == "./.github/actions/setup" ]] \ + || fail "the PR planner must use the repository-owned authenticated setup" +[[ $(yq eval '.jobs."rail-plan".steps[] | select(.id == "rail_setup") | .with."tools-mode"' "$CI") \ + == "rail" ]] \ + || fail "the PR planner must select the exact Cargo Rail install mode" +grep -Fq 'scripts/ci/setup-toolchain.sh "$TOOLCHAIN" "$TOOLCHAIN_COMPONENTS"' "$TOOLCHAIN_ACTION" \ + || fail "toolchain setup must use the repository-owned rustup policy" +if grep -Eq '[.]cargo/(bin|[.]crates)|[.]opam' "$SETUP_ACTION"; then + fail "CI tool executables and OPAM switches must not be restored from caches" +fi +[[ $(yq eval '.runs.steps[] | select(.name == "Setup Rust Cache") | .with."cache-bin"' "$SETUP_ACTION") \ + == "false" ]] || fail "the Rust build cache must exclude Cargo tool executables" +if grep -Fq 'export PATH="$HOME/.cargo/bin:$PATH"' "$CI_CHECK" "$HOST_CHECK"; then + fail "CI checks must not place unverified runner tools ahead of the authenticated tool root" +fi +grep -Fq 'RSCRYPTO_TOOL_ROOT=$(mktemp -d ' "$INSTALL_TOOLS" \ + || fail "package-manager tools must install into a fresh per-run root" +grep -Fq 'export CARGO_HOME="$RSCRYPTO_CARGO_HOME"' "$INSTALL_TOOLS" \ + || fail "Cargo tool sources and executables must use the fresh per-run root" +grep -Fq 'GOMODCACHE="$RSCRYPTO_TOOL_ROOT/go/pkg/mod"' "$INSTALL_TOOLS" \ + || fail "Go tool sources must use the fresh per-run root" +grep -Fq 'export OPAMROOT="$RSCRYPTO_TOOL_ROOT/opam"' "$INSTALL_TOOLS" \ + || fail "OPAM tool sources and executables must use the fresh per-run root" +grep -Fq 'cargo install --registry crates-io "$package" --locked --version "=$version" --force' "$INSTALL_TOOLS" \ + || fail "Cargo tools must use exact authenticated crates.io installs" +grep -Fq 'go install "github.com/rhysd/actionlint/cmd/actionlint@v$ACTIONLINT_VERSION"' "$INSTALL_TOOLS" \ + || fail "Go tools must use exact checksum-database-backed module versions" +opam_commit=$(sed -n 's/^OPAM_REPOSITORY_COMMIT=//p' "$INSTALL_TOOLS") +[[ "$opam_commit" =~ ^[0-9a-f]{40}$ ]] \ + || fail "OPAM repository must use a full Git commit" +grep -Fq 'actual=$(git -C "$repository" rev-parse HEAD)' "$INSTALL_TOOLS" \ + || fail "OPAM metadata must be checked against its pinned commit" +grep -Fq 'actual=$(dpkg-query -W -f=' "$INSTALL_TOOLS" \ + || fail "APT packages must be validated against exact versions" +grep -Fq 'ci_tool_download wasmtime' "$NOSTD_WASM" \ + || fail "Wasmtime must use the direct archive integrity contract" +grep -Fq 'ci_tool_download zig' "$CROSS_SCRIPT" \ + || fail "Zig must use the direct archive integrity contract" +grep -Fq 'ci_tool_download codecov' "$INSTALL_CODECOV" \ + || fail "Codecov must use the direct executable integrity contract" +[[ $(yq eval '.jobs.coverage.steps[] | select(.id == "codecov") | .run' "$WEEKLY") \ + == "scripts/ci/install-codecov.sh" ]] \ + || fail "Weekly coverage must install the authenticated Codecov CLI" +# shellcheck disable=SC2016 # GitHub expression is an intentional literal contract. +grep -Fq 'binary: ${{ steps.codecov.outputs.binary }}' "$WEEKLY" \ + || fail "Codecov action must use the repository-verified CLI" +[[ $(yq eval '.jobs.scorecard.steps[] | select(.name == "Run Scorecard") | .uses' "$SCORECARD") \ + == "./.github/actions/scorecard" ]] \ + || fail "Scorecard must use the repository-owned digest-pinned action" +scorecard_image=$(yq eval -r '.runs.image' "$SCORECARD_ACTION") +[[ "$scorecard_image" =~ ^docker://ghcr\.io/ossf/scorecard-action@sha256:[0-9a-f]{64}$ ]] \ + || fail "Scorecard container must use an OCI digest" + [[ $(count_feature_sets "$COMPILE_MATRIX") -eq 29 ]] \ || fail "compile feature matrix must retain all 29 profiles" [[ $(count_feature_sets "$EXECUTABLE_MATRIX") -eq 38 ]] \ diff --git a/scripts/ci/ci-check.sh b/scripts/ci/ci-check.sh index 0f76f049..700023ba 100755 --- a/scripts/ci/ci-check.sh +++ b/scripts/ci/ci-check.sh @@ -8,9 +8,6 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━ echo "🔍 CI Quality Checks" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -# Prefer cargo-installed tools over any preinstalled runner tools. -export PATH="$HOME/.cargo/bin:$PATH" - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" echo "" diff --git a/scripts/ci/cross-targets.sh b/scripts/ci/cross-targets.sh index b2806ed6..e78ca75c 100755 --- a/scripts/ci/cross-targets.sh +++ b/scripts/ci/cross-targets.sh @@ -13,32 +13,20 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" source "$SCRIPT_DIR/../lib/common.sh" # shellcheck source=../lib/targets.sh source "$SCRIPT_DIR/../lib/targets.sh" +# shellcheck source=../lib/ci-tool-integrity.sh +source "$SCRIPT_DIR/../lib/ci-tool-integrity.sh" maybe_disable_sccache -ZIG_VERSION="0.17.0-dev.1282+c0f9b51d8" -ZIG_SHA256="6d81dec0152f6f11f8a12a84a73535b65070fe01b14a1ce75423870d65b3f270" +zig_temp_root=${RUNNER_TEMP:-${TMPDIR:-/tmp}} +zig_root=$(mktemp -d "$zig_temp_root/rscrypto-zig.XXXXXX") +trap 'rm -rf "$zig_root"' EXIT +ci_tool_download zig "$zig_root" +tar -xJf "$CI_TOOL_ARCHIVE_PATH" --strip-components=1 -C "$zig_root" +export PATH="$zig_root:$PATH" -if ! command -v zig >/dev/null 2>&1; then - if [[ "$(uname -s)-$(uname -m)" != "Linux-x86_64" ]]; then - echo "error: Zig $ZIG_VERSION is required for MUSL target validation" >&2 - exit 1 - fi - - zig_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/zig-$ZIG_VERSION" - zig_archive="$(mktemp)" - trap 'rm -f "$zig_archive"' EXIT - curl -fsSL --proto '=https' --tlsv1.2 \ - "https://ziglang.org/builds/zig-x86_64-linux-$ZIG_VERSION.tar.xz" \ - -o "$zig_archive" - echo "$ZIG_SHA256 $zig_archive" | sha256sum --check --status - mkdir -p "$zig_root" - tar -xJf "$zig_archive" --strip-components=1 -C "$zig_root" - export PATH="$zig_root:$PATH" -fi - -if [[ "$(zig version)" != "$ZIG_VERSION" ]]; then - echo "error: expected Zig $ZIG_VERSION, found $(zig version)" >&2 +if [[ "$(zig version)" != "$CI_TOOL_VERSION" ]]; then + echo "error: expected Zig $CI_TOOL_VERSION, found $(zig version)" >&2 exit 1 fi diff --git a/scripts/ci/install-codecov.sh b/scripts/ci/install-codecov.sh new file mode 100755 index 00000000..a5e29858 --- /dev/null +++ b/scripts/ci/install-codecov.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/ci-tool-integrity.sh +source "$SCRIPT_DIR/../lib/ci-tool-integrity.sh" + +runner_temp=${RUNNER_TEMP:?RUNNER_TEMP is required} +install_dir=$(mktemp -d "$runner_temp/rscrypto-codecov.XXXXXX") +ci_tool_download codecov "$install_dir" +chmod +x "$CI_TOOL_ARCHIVE_PATH" + +version_output=$("$CI_TOOL_ARCHIVE_PATH" --version) +expected_version=${CI_TOOL_VERSION#v} +if [[ "$version_output" =~ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then + installed_version=${BASH_REMATCH[1]} +else + echo "CodeCov CLI version mismatch: expected $expected_version, got $version_output" >&2 + exit 1 +fi +[[ "$installed_version" == "$expected_version" ]] || { + echo "CodeCov CLI version mismatch: expected $expected_version, got $installed_version" >&2 + exit 1 +} + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf 'binary=%s\n' "$CI_TOOL_ARCHIVE_PATH" >>"$GITHUB_OUTPUT" +fi diff --git a/scripts/ci/install-tools.sh b/scripts/ci/install-tools.sh index 0134efa8..a568bd6c 100755 --- a/scripts/ci/install-tools.sh +++ b/scripts/ci/install-tools.sh @@ -1,457 +1,334 @@ #!/usr/bin/env bash -# Install cargo tools for CI. -# Usage: install-tools.sh [standard|quality|rail|ci|supply-chain|bench|ibm|fuzz|coverage|ct-linux|minimal|none] +# Install exact CI tools through authenticated package-manager boundaries. +# Usage: install-tools.sh [standard|quality|release|rail|ci|supply-chain|bench|ibm|fuzz|coverage|ct-linux|minimal|none] set -euo pipefail -MODE="${1:-standard}" -CARGO_RAIL_VERSION="${CARGO_RAIL_VERSION:-0.18.0}" -CARGO_SEMVER_CHECKS_VERSION="${CARGO_SEMVER_CHECKS_VERSION:-0.48.0}" -ACTIONLINT_VERSION="${ACTIONLINT_VERSION:-1.7.12}" -ZIZMOR_VERSION="${ZIZMOR_VERSION:-1.26.1}" - -echo "Installing cargo tools (mode: $MODE)" - -# Tool-free jobs: do nothing (fast path). -if [[ "$MODE" == "none" ]]; then - echo "Skipping tool installation (mode: none)" - exit 0 -fi - -# Prefer cargo-installed tools over any preinstalled runner tools. -export PATH="$HOME/.cargo/bin:$PATH" - -# Check if cargo-binstall is available -install_binstall() { - if command -v cargo-binstall &>/dev/null; then - echo "cargo-binstall already installed" - return 0 - fi - - echo "Installing cargo-binstall..." - - # Downloads and installs cargo-binstall from a GitHub release. - # Uses a subshell to isolate the EXIT trap and temporary directory handling. - install_binstall_from_release() ( - set -euo pipefail - local target="$1" - local tmpdir - tmpdir="$(mktemp -d)" - trap 'rm -rf "$tmpdir"' EXIT - - local base_url - if [[ -n "${BINSTALL_VERSION:-}" ]]; then - base_url="https://github.com/cargo-bins/cargo-binstall/releases/download/v${BINSTALL_VERSION}/cargo-binstall-" - else - base_url="https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-" - fi - - local url="${base_url}${target}.tgz" - - echo " trying $target" - cd "$tmpdir" - - # Avoid `curl | tar` here: with `set -euo pipefail`, a 404 can terminate the whole script - # on some bash versions even when the function is invoked under `if ...; then`. - if ! curl -L --proto '=https' --tlsv1.2 -sSf -o cargo-binstall.tgz "$url"; then - exit 1 - fi - tar -xzf cargo-binstall.tgz - mkdir -p "$HOME/.cargo/bin" - mv cargo-binstall "$HOME/.cargo/bin/" - chmod +x "$HOME/.cargo/bin/cargo-binstall" - ) +MODE=${1:-standard} + +CARGO_NEXTEST_VERSION=0.9.140 +CARGO_DENY_VERSION=0.20.2 +CARGO_AUDIT_VERSION=0.22.2 +CARGO_RAIL_VERSION=0.18.0 +CARGO_SEMVER_CHECKS_VERSION=0.48.0 +JUST_VERSION=1.57.0 +ZIZMOR_VERSION=1.26.1 +CARGO_CRITERION_VERSION=1.1.0 +CRITCMP_VERSION=0.1.8 +CARGO_FUZZ_VERSION=0.13.2 +CARGO_LLVM_COV_VERSION=0.8.7 +ACTIONLINT_VERSION=1.7.12 + +OPAM_REPOSITORY_COMMIT=49f6d620cf20ae0168cfcbeb2c33932e06cb4b74 +OPAM_REPOSITORY_URL="git+https://github.com/ocaml/opam-repository.git#$OPAM_REPOSITORY_COMMIT" +OCAML_COMPILER_PACKAGE=ocaml-base-compiler.5.2.1 +BINSEC_PACKAGE=binsec.0.11.1 +BINSEC_DECODER_PACKAGE=unisim_archisec.0.0.14 +BINSEC_SOLVER_PACKAGES=(bitwuzla.1.0.6 bitwuzla-cxx.0.9.0) + +BINSEC_APT_PACKAGES=( + build-essential=12.10ubuntu1 + git=1:2.43.0-1ubuntu7.3 + libgmp-dev=2:6.3.0+dfsg-2ubuntu6 + libmpfr-dev=4.2.1-1build1 + m4=1.4.19-4build1 + opam=2.1.5-1 + pkg-config=1.8.1-2build1 + zlib1g-dev=1:1.3.dfsg-3.1ubuntu2 +) +MUSL_APT_PACKAGE=musl-tools=1.2.4-2 + +RSCRYPTO_TOOL_TEMP=${RUNNER_TEMP:-${TMPDIR:-/tmp}} +[[ -d "$RSCRYPTO_TOOL_TEMP" ]] || { + echo "CI tool install error: temporary root is not a directory: $RSCRYPTO_TOOL_TEMP" >&2 + exit 1 +} +RSCRYPTO_TOOL_ROOT=$(mktemp -d "$RSCRYPTO_TOOL_TEMP/rscrypto-ci-tools.XXXXXX") +RSCRYPTO_CARGO_HOME="$RSCRYPTO_TOOL_ROOT/cargo" +RSCRYPTO_CARGO_BIN="$RSCRYPTO_CARGO_HOME/bin" +mkdir -p "$RSCRYPTO_CARGO_BIN" +export CARGO_HOME="$RSCRYPTO_CARGO_HOME" +export CARGO_TARGET_DIR="$RSCRYPTO_TOOL_ROOT/cargo-target" +export PATH="$RSCRYPTO_CARGO_BIN:$PATH" + +fail() { + echo "CI tool install error: $*" >&2 + exit 1 +} - # Detect Windows ARM64 specially - uname -m returns x86_64 due to emulation layer - # PROCESSOR_ARCHITECTURE is the reliable way to detect native arch on Windows - if [[ "${PROCESSOR_ARCHITECTURE:-}" == "ARM64" ]]; then - echo "Detected Windows ARM64 (via PROCESSOR_ARCHITECTURE)" - BINSTALL_URL="https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-aarch64-pc-windows-msvc.zip" - BINSTALL_ZIP="cargo-binstall-aarch64-pc-windows-msvc.zip" - - # Download and extract manually - curl -L --proto '=https' --tlsv1.2 -sSf -o "$BINSTALL_ZIP" "$BINSTALL_URL" - unzip -q "$BINSTALL_ZIP" - mkdir -p "$HOME/.cargo/bin" - mv cargo-binstall.exe "$HOME/.cargo/bin/" - rm -f "$BINSTALL_ZIP" - echo "✅ cargo-binstall installed (Windows ARM64)" +extract_version() { + local output=$1 + if [[ "$output" =~ ([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" else - # The upstream bootstrap script tries `*-unknown-linux-musl` on many arches; - # cargo-binstall doesn't publish MUSL binaries for all CI targets (notably s390x/ppc64le). - # Prefer a release binary when available; otherwise fall back to `cargo install`. - - local os machine - local -a targets - os="$(uname -s)" - machine="$(uname -m)" - - case "$os" in - Linux) - case "$machine" in - x86_64) targets=(x86_64-unknown-linux-musl x86_64-unknown-linux-gnu) ;; - aarch64) targets=(aarch64-unknown-linux-musl aarch64-unknown-linux-gnu) ;; - armv7l) targets=(armv7-unknown-linux-musleabihf armv7-unknown-linux-gnueabihf) ;; - s390x) targets=(s390x-unknown-linux-gnu) ;; - ppc64le | powerpc64le) targets=(powerpc64le-unknown-linux-gnu) ;; - *) targets=() ;; - esac - ;; - Darwin) - case "$machine" in - x86_64) targets=(x86_64-apple-darwin) ;; - arm64) targets=(aarch64-apple-darwin) ;; - *) targets=() ;; - esac - ;; - *) - targets=() - ;; - esac - - for t in "${targets[@]}"; do - if install_binstall_from_release "$t"; then - echo "✅ cargo-binstall installed ($t)" - return 0 - fi - done - - echo " no prebuilt cargo-binstall for ${os}/${machine}; building from source..." - if [[ -n "${BINSTALL_VERSION:-}" ]]; then - cargo install cargo-binstall --locked --version "${BINSTALL_VERSION}" - else - cargo install cargo-binstall --locked - fi + return 1 fi } -# Install a tool if not already present -install_if_missing() { - local tool="$1" - local binary="${2:-$tool}" - - if command -v "$binary" &>/dev/null; then - echo " $tool: cached" - return 0 +cargo_tool_path() { + local binary=$1 + if [[ -x "$RSCRYPTO_CARGO_BIN/$binary" ]]; then + printf '%s\n' "$RSCRYPTO_CARGO_BIN/$binary" + elif [[ -x "$RSCRYPTO_CARGO_BIN/$binary.exe" ]]; then + printf '%s\n' "$RSCRYPTO_CARGO_BIN/$binary.exe" + else + return 1 fi - - echo " $tool: installing..." - cargo binstall "$tool" --no-confirm --force 2>/dev/null || cargo install "$tool" --locked } -install_actionlint() { - if command -v actionlint &>/dev/null; then - echo " actionlint: cached" - return 0 - fi - - echo " actionlint: installing..." - GOBIN="$HOME/.cargo/bin" go install "github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}" +cargo_tool_version() { + local binary=$1 + local path=$2 + local output + case "$binary" in + cargo-rail) output=$("$path" rail --version 2>&1) ;; + cargo-llvm-cov) output=$("$path" llvm-cov --version 2>&1) ;; + *) output=$("$path" --version 2>&1) ;; + esac + extract_version "$output" } -install_zizmor() { - if command -v zizmor &>/dev/null; then - echo " zizmor: cached" - return 0 - fi - - echo " zizmor: installing..." - cargo binstall "zizmor@${ZIZMOR_VERSION}" --no-confirm --force 2>/dev/null \ - || cargo install zizmor --locked --version "$ZIZMOR_VERSION" +cargo_metadata_has() { + local package=$1 + local version=$2 + local installed + installed=$(cargo install --list) + grep -Fqx "$package v$version:" <<<"$installed" } -install_just_portable() { - if command -v just &>/dev/null; then - echo " just: cached" - return 0 - fi - - echo " just: installing via cargo" - cargo install just --locked +verify_cargo_tool() { + local package=$1 + local version=$2 + local binary=$3 + local path actual + + cargo_metadata_has "$package" "$version" \ + || fail "$package install metadata does not record exact version $version" + path=$(cargo_tool_path "$binary") \ + || fail "$package metadata exists but $binary is missing or non-executable" + actual=$(cargo_tool_version "$binary" "$path") \ + || fail "unable to read $binary version after authentication" + [[ "$actual" == "$version" ]] \ + || fail "$binary reports $actual, expected $version" } -# Compare dot-separated numeric versions. Returns 0 when $1 >= $2. -version_gte() { - local lhs="$1" - local rhs="$2" - local IFS=. - local -a a b - - read -r -a a <<<"$lhs" - read -r -a b <<<"$rhs" - - local i max len_a len_b - len_a=${#a[@]} - len_b=${#b[@]} - max=$((len_a > len_b ? len_a : len_b)) - - for ((i = 0; i < max; i++)); do - local ai="${a[i]:-0}" - local bi="${b[i]:-0}" - if ((10#$ai > 10#$bi)); then - return 0 - fi - if ((10#$ai < 10#$bi)); then - return 1 - fi - done +install_cargo_tool() { + local package=$1 + local version=$2 + local binary=${3:-$package} - return 0 + echo " $package: installing $version from crates.io into a fresh root" + cargo install --registry crates-io "$package" --locked --version "=$version" --force + verify_cargo_tool "$package" "$version" "$binary" } -ensure_cargo_rail() { - local required="$1" - local installed="" - - if command -v cargo-rail &>/dev/null; then - installed="$(cargo rail --version 2>/dev/null | awk '{print $2}' || true)" - if [[ -n "$installed" ]] && version_gte "$installed" "$required"; then - echo " cargo-rail: cached ($installed)" - return 0 - fi - echo " cargo-rail: stale ($installed), upgrading to >= $required" - else - echo " cargo-rail: installing (required >= $required)" - fi +install_actionlint() { + local binary="$RSCRYPTO_CARGO_BIN/actionlint" + [[ "$(uname -s)" == MINGW* || "$(uname -s)" == MSYS* || "$(uname -s)" == CYGWIN* ]] \ + && binary+=.exe + + echo " actionlint: installing $ACTIONLINT_VERSION through the Go checksum database" + GOBIN="$RSCRYPTO_CARGO_BIN" \ + GOPATH="$RSCRYPTO_TOOL_ROOT/go" \ + GOMODCACHE="$RSCRYPTO_TOOL_ROOT/go/pkg/mod" \ + GOCACHE="$RSCRYPTO_TOOL_ROOT/go-build" \ + GOPROXY=https://proxy.golang.org \ + GOSUMDB=sum.golang.org \ + GOPRIVATE='' \ + GONOSUMDB='' \ + GOINSECURE='' \ + go install "github.com/rhysd/actionlint/cmd/actionlint@v$ACTIONLINT_VERSION" + + [[ -x "$binary" ]] || fail "actionlint install did not produce an executable" + local actual + actual=$(extract_version "$("$binary" -version 2>&1)") \ + || fail "unable to read actionlint version" + [[ "$actual" == "$ACTIONLINT_VERSION" ]] \ + || fail "actionlint reports $actual, expected $ACTIONLINT_VERSION" +} - cargo binstall "cargo-rail@${required}" --no-confirm --force 2>/dev/null || cargo install cargo-rail --locked --version "$required" --force +require_ubuntu_24_04() { + [[ -f /etc/os-release ]] || fail "Ubuntu 24.04 package metadata is required" + local os_id os_version + os_id=$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"') + os_version=$(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"') + [[ "$os_id" == ubuntu && "$os_version" == 24.04 ]] \ + || fail "exact APT pins support Ubuntu 24.04, found $os_id $os_version" } -ensure_cargo_semver_checks() { - local required="$1" - local installed="" - - if command -v cargo-semver-checks &>/dev/null; then - installed="$(cargo-semver-checks --version 2>/dev/null | awk '{print $2}' || true)" - if [[ -n "$installed" ]] && version_gte "$installed" "$required"; then - echo " cargo-semver-checks: cached ($installed)" - return 0 - fi - echo " cargo-semver-checks: stale ($installed), upgrading to >= $required" - else - echo " cargo-semver-checks: installing (required >= $required)" - fi +apt_install_exact() { + require_ubuntu_24_04 + command -v apt-get >/dev/null 2>&1 || fail "apt-get is required" + command -v dpkg-query >/dev/null 2>&1 || fail "dpkg-query is required" - cargo binstall "cargo-semver-checks@${required}" --no-confirm --force 2>/dev/null || cargo install cargo-semver-checks --locked --version "$required" --force + sudo apt-get update + sudo apt-get install -y --no-install-recommends --allow-downgrades "$@" + + local specification package expected actual + for specification in "$@"; do + package=${specification%%=*} + expected=${specification#*=} + actual=$(dpkg-query -W -f='${Version}' "$package") \ + || fail "APT did not install $package" + [[ "$actual" == "$expected" ]] \ + || fail "APT installed $package $actual, expected $expected" + done } install_binsec_system_packages() { - if [[ "$(uname -s)" != "Linux" ]]; then - return 0 - fi - if ! command -v apt-get &>/dev/null; then - return 0 + if [[ "$(uname -s)" != Linux ]]; then + fail "BINSEC installation is supported only on Linux" fi - if [[ "${BINSEC_SYSTEM_PACKAGES_READY:-}" == "1" ]]; then + if [[ "${BINSEC_SYSTEM_PACKAGES_READY:-}" == 1 ]]; then return 0 fi - - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential \ - git \ - libgmp-dev \ - libmpfr-dev \ - m4 \ - opam \ - pkg-config \ - zlib1g-dev + apt_install_exact "${BINSEC_APT_PACKAGES[@]}" BINSEC_SYSTEM_PACKAGES_READY=1 } -install_binsec() { - if [[ "$(uname -s)" != "Linux" ]]; then - echo " binsec: skipped (Linux-only CT kernel lane)" - return 0 - fi +verify_opam_repository() { + local repository="$OPAMROOT/repo/default" + [[ -d "$repository/.git" ]] \ + || fail "OPAM repository is not the pinned Git checkout" + local actual + actual=$(git -C "$repository" rev-parse HEAD) + [[ "$actual" == "$OPAM_REPOSITORY_COMMIT" ]] \ + || fail "OPAM repository is $actual, expected $OPAM_REPOSITORY_COMMIT" +} - local package="${BINSEC_OPAM_PACKAGE:-binsec.0.11.1}" - local decoder_package="${BINSEC_DECODER_OPAM_PACKAGE:-unisim_archisec.0.0.14}" - local solver_packages_raw="${BINSEC_SOLVER_OPAM_PACKAGES:-bitwuzla.1.0.6 bitwuzla-cxx.0.9.0}" - local -a solver_packages - read -r -a solver_packages <<< "$solver_packages_raw" +opam_package_is_installed() { + local package=$1 + local installed + installed=$(opam list --switch="$OPAMSWITCH" --installed --short --columns=package) + grep -Fqx "$package" <<<"$installed" +} - install_binsec_system_packages +verify_opam_packages() { + local package + for package in "$OCAML_COMPILER_PACKAGE" "$BINSEC_DECODER_PACKAGE" \ + "${BINSEC_SOLVER_PACKAGES[@]}" "$BINSEC_PACKAGE"; do + opam_package_is_installed "$package" \ + || fail "OPAM switch is missing exact package $package" + done +} - echo " binsec: installing via OPAM" - if ! command -v opam &>/dev/null; then - echo "opam is required to install BINSEC on this runner" >&2 - return 1 - fi +install_binsec() { + install_binsec_system_packages export OPAMYES=1 - export OPAMSWITCH="${OPAMSWITCH:-rscrypto-ct}" + export OPAMROOT="$RSCRYPTO_TOOL_ROOT/opam" + export OPAMSWITCH=rscrypto-ct - if [[ ! -d "$HOME/.opam" ]]; then - opam init --bare --disable-sandboxing -y - fi + opam init --bare --disable-sandboxing --no-setup --no-opamrc -y \ + default "$OPAM_REPOSITORY_URL" + verify_opam_repository - if ! opam switch list --short | grep -qx "$OPAMSWITCH"; then - opam switch create "$OPAMSWITCH" ocaml-base-compiler.5.2.1 -y - fi - - eval "$(opam env --switch="$OPAMSWITCH" --set-switch)" - local -a required_packages=("$decoder_package" "${solver_packages[@]}" "$package") - local -a missing_packages=() - local installed_packages - installed_packages="$(opam list --installed --short)" - - local required - for required in "${required_packages[@]}"; do - if ! grep -qx "${required%%.*}" <<<"$installed_packages"; then - missing_packages+=("$required") - fi - done + opam switch create "$OPAMSWITCH" "$OCAML_COMPILER_PACKAGE" \ + --repositories=default -y - if command -v binsec &>/dev/null && [[ "${#missing_packages[@]}" -eq 0 ]]; then - echo " binsec: cached" - else - if [[ "${#missing_packages[@]}" -gt 0 ]]; then - echo " binsec: installing missing OPAM package(s): ${missing_packages[*]}" - else - echo " binsec: binary missing from PATH; reinstalling $package" - fi - opam install "${required_packages[@]}" -y - # BINSEC links optional solver bindings at build time; reinstall if a cached - # switch gained solver packages after BINSEC was first installed. - opam reinstall "$package" -y - fi - eval "$(opam env --switch="$OPAMSWITCH" --set-switch)" + local -a required_packages=( + "$BINSEC_DECODER_PACKAGE" + "${BINSEC_SOLVER_PACKAGES[@]}" + "$BINSEC_PACKAGE" + ) + opam install --switch="$OPAMSWITCH" "${required_packages[@]}" -y + opam reinstall --switch="$OPAMSWITCH" "$BINSEC_PACKAGE" -y + verify_opam_packages + + local switch_bin + switch_bin=$(opam var bin --switch="$OPAMSWITCH") + [[ -x "$switch_bin/binsec" ]] \ + || fail "authenticated OPAM switch is missing binsec" + export PATH="$switch_bin:$PATH" + + local actual + actual=$(extract_version "$("$switch_bin/binsec" -version 2>&1)") \ + || fail "unable to read BINSEC version" + [[ "$actual" == "${BINSEC_PACKAGE#binsec.}" ]] \ + || fail "BINSEC reports $actual, expected ${BINSEC_PACKAGE#binsec.}" if [[ -n "${GITHUB_PATH:-}" ]]; then - echo "$HOME/.opam/$OPAMSWITCH/bin" >> "$GITHUB_PATH" + echo "$switch_bin" >>"$GITHUB_PATH" fi - export PATH="$HOME/.opam/$OPAMSWITCH/bin:$PATH" - - binsec -version || true } install_ct_linux_packages() { - if [[ "$(uname -s)" != "Linux" ]]; then - return 0 - fi - if ! command -v apt-get &>/dev/null; then - return 0 - fi - install_binsec_system_packages - sudo apt-get install -y --no-install-recommends musl-tools + apt_install_exact "${BINSEC_APT_PACKAGES[@]}" "$MUSL_APT_PACKAGE" + BINSEC_SYSTEM_PACKAGES_READY=1 } -echo "" -echo "Installing tools for mode: $MODE" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Installing CI tools (mode: $MODE)" case "$MODE" in standard) - # Standard CI tools (same for all CI runners) - install_binstall - install_if_missing "cargo-nextest" "cargo-nextest" - install_if_missing "cargo-deny" "cargo-deny" - install_if_missing "cargo-audit" "cargo-audit" - ensure_cargo_rail "$CARGO_RAIL_VERSION" - ensure_cargo_semver_checks "$CARGO_SEMVER_CHECKS_VERSION" - install_if_missing "just" "just" + install_cargo_tool cargo-nextest "$CARGO_NEXTEST_VERSION" + install_cargo_tool cargo-deny "$CARGO_DENY_VERSION" + install_cargo_tool cargo-audit "$CARGO_AUDIT_VERSION" + install_cargo_tool cargo-rail "$CARGO_RAIL_VERSION" + install_cargo_tool cargo-semver-checks "$CARGO_SEMVER_CHECKS_VERSION" + install_cargo_tool just "$JUST_VERSION" ;; - quality) - install_binstall - install_if_missing "just" "just" + install_cargo_tool just "$JUST_VERSION" install_actionlint - install_zizmor + install_cargo_tool zizmor "$ZIZMOR_VERSION" ;; - release) - # Tag preflight owns graph, supply-chain, and package validation. - install_binstall - ensure_cargo_rail "$CARGO_RAIL_VERSION" - ensure_cargo_semver_checks "$CARGO_SEMVER_CHECKS_VERSION" - install_if_missing "cargo-deny" "cargo-deny" - install_if_missing "cargo-audit" "cargo-audit" + install_cargo_tool cargo-rail "$CARGO_RAIL_VERSION" + install_cargo_tool cargo-semver-checks "$CARGO_SEMVER_CHECKS_VERSION" + install_cargo_tool cargo-deny "$CARGO_DENY_VERSION" + install_cargo_tool cargo-audit "$CARGO_AUDIT_VERSION" ;; - rail) - # Compiler-backed Cargo graph assurance. - install_binstall - ensure_cargo_rail "$CARGO_RAIL_VERSION" + install_cargo_tool cargo-rail "$CARGO_RAIL_VERSION" ;; - ci) - # Native CI tools: test runner + just wrapper only. Supply-chain tooling - # belongs to the dedicated supply-chain lane. - install_binstall - install_if_missing "cargo-nextest" "cargo-nextest" - install_if_missing "just" "just" + install_cargo_tool cargo-nextest "$CARGO_NEXTEST_VERSION" + install_cargo_tool just "$JUST_VERSION" ;; - supply-chain) - install_binstall - install_if_missing "cargo-deny" "cargo-deny" - install_if_missing "cargo-audit" "cargo-audit" + install_cargo_tool cargo-deny "$CARGO_DENY_VERSION" + install_cargo_tool cargo-audit "$CARGO_AUDIT_VERSION" install_actionlint - install_zizmor + install_cargo_tool zizmor "$ZIZMOR_VERSION" ;; - ibm) - # IBM runners: keep installs minimal and fast. - # Skip cargo-binstall entirely on these platforms since it requires source - # compilation; just install 'just' directly via cargo. - # `cargo-nextest` often lacks prebuilt binaries on s390x/ppc64le and falls - # back to expensive source builds; test.sh will use `cargo test` fallback. - if command -v just &>/dev/null; then - echo " just: cached" - else - echo " just: installing via cargo (skipping binstall for speed)..." - cargo install just --locked - fi + install_cargo_tool just "$JUST_VERSION" ;; - bench) - # Benchmark tools (Criterion + tuning) - install_binstall - install_if_missing "cargo-criterion" "cargo-criterion" - install_if_missing "critcmp" "critcmp" - install_if_missing "just" "just" + install_cargo_tool cargo-criterion "$CARGO_CRITERION_VERSION" + install_cargo_tool critcmp "$CRITCMP_VERSION" + install_cargo_tool just "$JUST_VERSION" ;; - fuzz) - # Weekly fuzz lane: keep tool set minimal and explicit. - install_binstall - install_if_missing "cargo-fuzz" "cargo-fuzz" - install_if_missing "just" "just" + install_cargo_tool cargo-fuzz "$CARGO_FUZZ_VERSION" + install_cargo_tool just "$JUST_VERSION" ;; - coverage) - # Coverage reporting: nextest + deterministic fuzz corpus replay. - install_binstall - install_if_missing "cargo-llvm-cov" "cargo-llvm-cov" - install_if_missing "cargo-nextest" "cargo-nextest" - install_if_missing "just" "just" - # llvm-tools-preview provides llvm-profdata/llvm-cov for coverage reporting. - rustup component add llvm-tools-preview 2>/dev/null || true + install_cargo_tool cargo-llvm-cov "$CARGO_LLVM_COV_VERSION" + install_cargo_tool cargo-nextest "$CARGO_NEXTEST_VERSION" + install_cargo_tool just "$JUST_VERSION" + rustup component add llvm-tools-preview ;; - ct-linux) - install_just_portable + install_cargo_tool just "$JUST_VERSION" install_ct_linux_packages install_binsec ;; - minimal) - # Minimal set for quick jobs - install_binstall - install_if_missing "just" "just" + install_cargo_tool just "$JUST_VERSION" ;; - none) - # Tool-free jobs: use whatever is already present in the toolchain image. ;; - *) - echo "Unknown mode: $MODE" - echo "Usage: install-tools.sh [standard|quality|rail|ci|supply-chain|bench|ibm|fuzz|coverage|ct-linux|minimal|none]" - exit 1 + echo "Unknown mode: $MODE" >&2 + echo "Usage: install-tools.sh [standard|quality|release|rail|ci|supply-chain|bench|ibm|fuzz|coverage|ct-linux|minimal|none]" >&2 + exit 2 ;; esac -echo "" -echo "Tool installation complete" +if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$RSCRYPTO_CARGO_BIN" >>"$GITHUB_PATH" +fi + +echo "CI tool installation complete" diff --git a/scripts/ci/nostd-wasm-suite.sh b/scripts/ci/nostd-wasm-suite.sh index 399697e1..0125984f 100755 --- a/scripts/ci/nostd-wasm-suite.sh +++ b/scripts/ci/nostd-wasm-suite.sh @@ -12,52 +12,40 @@ set -euo pipefail TARGET="${1:?usage: nostd-wasm-suite.sh }" DEPTH="${2:-shallow}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/ci-tool-integrity.sh +source "$SCRIPT_DIR/../lib/ci-tool-integrity.sh" rustup target add "$TARGET" install_wasmtime() { - if command -v wasmtime >/dev/null 2>&1; then - return - fi - - local version="${WASMTIME_VERSION:-v46.0.1}" - local host_os host_arch platform archive_name archive_url tmpdir archive install_dir - - case "$(uname -s)" in - Linux) host_os="linux" ;; - Darwin) host_os="macos" ;; - *) - echo "unsupported Wasmtime host OS: $(uname -s)" >&2 - return 1 - ;; - esac - - case "$(uname -m)" in - x86_64 | amd64) host_arch="x86_64" ;; - aarch64 | arm64) host_arch="aarch64" ;; - *) - echo "unsupported Wasmtime host arch: $(uname -m)" >&2 - return 1 - ;; - esac - - platform="${host_arch}-${host_os}" - archive_name="wasmtime-${version}-${platform}.tar.xz" - archive_url="https://github.com/bytecodealliance/wasmtime/releases/download/${version}/${archive_name}" + local platform tmpdir install_dir installed_version expected_version tmpdir="$(mktemp -d)" - archive="${tmpdir}/${archive_name}" install_dir="${WASMTIME_HOME:-$HOME/.wasmtime}" - echo "Installing Wasmtime ${version} for ${platform}" - curl --fail --location --show-error --silent --retry 3 --retry-delay 2 --output "$archive" "$archive_url" - tar -xJf "$archive" -C "$tmpdir" + ci_tool_download wasmtime "$tmpdir" + platform="${CI_TOOL_HOST_ARCH}-${CI_TOOL_HOST_OS}" + echo "Installing Wasmtime $CI_TOOL_VERSION for $platform" + tar -xJf "$CI_TOOL_ARCHIVE_PATH" -C "$tmpdir" mkdir -p "$install_dir/bin" - cp "$tmpdir/wasmtime-${version}-${platform}/wasmtime" "$install_dir/bin/wasmtime" + cp "$tmpdir/wasmtime-${CI_TOOL_VERSION}-${platform}/wasmtime" "$install_dir/bin/wasmtime" chmod +x "$install_dir/bin/wasmtime" - rm -rf "$tmpdir" + installed_version=$("$install_dir/bin/wasmtime" --version) + expected_version=${CI_TOOL_VERSION#v} + if [[ "$installed_version" =~ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then + installed_version=${BASH_REMATCH[1]} + else + echo "Wasmtime version mismatch: expected $expected_version, got $installed_version" >&2 + return 1 + fi + [[ "$installed_version" == "$expected_version" ]] || { + echo "Wasmtime version mismatch: expected $expected_version, got $installed_version" >&2 + return 1 + } + + rm -rf "$tmpdir" export PATH="$install_dir/bin:$PATH" - wasmtime --version } run_wasm_runtime_vectors() { diff --git a/scripts/ci/pre-push-test.sh b/scripts/ci/pre-push-test.sh index 46f8e3f3..5b0da949 100755 --- a/scripts/ci/pre-push-test.sh +++ b/scripts/ci/pre-push-test.sh @@ -104,6 +104,29 @@ run_pre_push_case malformed-plan '{' '' 0 true run_pre_push_case missing-scope "$(jq -c 'del(.scope)' <<<"$plan")" '' 0 true run_pre_push_case valid-empty "$empty_plan" '' 0 false +installer_scope=$(jq -c '.surfaces.infra = true' <<<"$empty_scope") +installer_plan=$(jq -cn --argjson scope "$installer_scope" \ + '{schema_version:1,command:"plan",mode:"inspect",result:"success",exit_code:0,plan_contract_version:3,files:[{path:"scripts/ci/install-tools.sh"}],scope:$scope}') +: >"$mock_log" +if ! ( + cd "$fixture" + HOME="$TMP_ROOT/home" \ + PATH="$fake_bin:$PATH" \ + MOCK_LOG="$mock_log" \ + MOCK_RELEASE_CHECK_STATUS=0 \ + RAIL_PLAN_JSON_CACHE="$installer_plan" \ + RAIL_SCOPE_JSON='' \ + RAIL_SCOPE_JSON_CACHE='' \ + scripts/ci/pre-push.sh --light +) >/dev/null 2>&1; then + echo "pre-push installer-integrity routing failed" >&2 + exit 1 +fi +grep -Fq 'just check-actions' "$mock_log" || { + echo "installer integrity changes did not select check-actions" >&2 + exit 1 +} + plan_fixture="$TMP_ROOT/plan-repository" mkdir -p "$plan_fixture/.config" "$plan_fixture/scripts" "$plan_fixture/src" cp "$REPO_ROOT/.config/rail.toml" "$plan_fixture/.config/rail.toml" diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh index 65d55b2d..04ee67c3 100755 --- a/scripts/ci/pre-push.sh +++ b/scripts/ci/pre-push.sh @@ -180,7 +180,7 @@ needs_actions_check() { return 0 fi - changed_file_matches '^\.config/target-matrix\.json$|^\.github/(workflows|actions)/.*\.ya?ml$|^\.github/(rulesets|repository-settings)/.*\.json$|^scripts/ci/(check-action-pins|check-action-pins-test|check-ci-ownership|check-ci-ownership-test|release-evidence-check|release-evidence-check-test|repository-controls-evidence|repository-controls-evidence-test|package-release-source|write-release-manifest|release-identity-test|publish-immutable-release|publish-immutable-release-test|release-recipes-test)\.sh$' + changed_file_matches '^\.config/(ci-tool-archives\.tsv|target-matrix\.json)$|^\.github/(workflows|actions)/.*\.ya?ml$|^\.github/(rulesets|repository-settings)/.*\.json$|^scripts/check/check\.sh$|^scripts/ci/(check-action-pins|check-action-pins-test|check-ci-ownership|check-ci-ownership-test|ci-check|cross-targets|install-codecov|install-tools|nostd-wasm-suite|setup-toolchain|tool-integrity-test|release-evidence-check|release-evidence-check-test|repository-controls-evidence|repository-controls-evidence-test|package-release-source|write-release-manifest|release-identity-test|publish-immutable-release|publish-immutable-release-test|release-recipes-test)\.sh$|^scripts/lib/ci-tool-integrity\.sh$' } needs_host_checks() { diff --git a/scripts/ci/setup-toolchain.sh b/scripts/ci/setup-toolchain.sh new file mode 100755 index 00000000..beba45ec --- /dev/null +++ b/scripts/ci/setup-toolchain.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "usage: setup-toolchain.sh [comma-separated-components]" >&2 + exit 2 +fi + +toolchain=$1 +components=${2:-} + +if [[ ! "$toolchain" =~ ^(nightly|beta)-[0-9]{4}-[0-9]{2}-[0-9]{2}$ \ + && ! "$toolchain" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "toolchain must be an exact dated or release version: $toolchain" >&2 + exit 1 +fi + +if ! command -v rustup >/dev/null 2>&1; then + echo "rustup must be present in the authenticated runner image; network bootstrap is prohibited" >&2 + exit 1 +fi + +install_args=(toolchain install "$toolchain" --profile minimal --no-self-update) +if [[ -n "$components" ]]; then + IFS=',' read -r -a requested_components <<<"$components" + for component in "${requested_components[@]}"; do + component=${component//[[:space:]]/} + [[ "$component" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { + echo "invalid rustup component: $component" >&2 + exit 1 + } + install_args+=(--component "$component") + done +fi + +rustup "${install_args[@]}" +rustup default "$toolchain" +rustc "+$toolchain" --version --verbose diff --git a/scripts/ci/tool-integrity-test.sh b/scripts/ci/tool-integrity-test.sh new file mode 100755 index 00000000..50d521c2 --- /dev/null +++ b/scripts/ci/tool-integrity-test.sh @@ -0,0 +1,551 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +TMP_ROOT=$(mktemp -d) +trap 'rm -rf "$TMP_ROOT"' EXIT + +fail() { + echo "tool integrity test failure: $*" >&2 + exit 1 +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +make_direct_fixture() { + local fixture=$1 + mkdir -p "$fixture/.config" "$fixture/scripts/ci" "$fixture/scripts/lib" + cp "$REPO_ROOT/.config/ci-tool-archives.tsv" "$fixture/.config/" + cp "$REPO_ROOT/scripts/lib/ci-tool-integrity.sh" "$fixture/scripts/lib/" + cp "$REPO_ROOT/scripts/ci/install-codecov.sh" "$REPO_ROOT/scripts/ci/nostd-wasm-suite.sh" \ + "$fixture/scripts/ci/" +} + +set_manifest_digest() { + local fixture=$1 + local tool=$2 + local os=$3 + local architecture=$4 + local digest=$5 + local manifest="$fixture/.config/ci-tool-archives.tsv" + awk -F '\t' -v OFS='\t' \ + -v tool="$tool" -v os="$os" -v architecture="$architecture" -v digest="$digest" ' + $1 == tool && $3 == os && $4 == architecture { $7 = digest } + { print } + ' "$manifest" >"$manifest.tmp" + mv "$manifest.tmp" "$manifest" +} + +direct_bin="$TMP_ROOT/direct-bin" +direct_log="$TMP_ROOT/direct.log" +mkdir -p "$direct_bin" + +cat >"$direct_bin/uname" <<'SH' +#!/usr/bin/env bash +case "${1:-}" in + -s) printf '%s\n' "${MOCK_UNAME_S:-Linux}" ;; + -m) printf '%s\n' "${MOCK_UNAME_M:-x86_64}" ;; + *) printf '%s %s\n' "${MOCK_UNAME_S:-Linux}" "${MOCK_UNAME_M:-x86_64}" ;; +esac +SH + +cat >"$direct_bin/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'curl %s\n' "$*" >>"$MOCK_COMMAND_LOG" +[[ "${MOCK_CURL_FAIL:-0}" != 1 ]] || exit 22 +output="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output | -o) + output=$2 + shift 2 + ;; + *) shift ;; + esac +done +[[ -n "$output" ]] +cp "$MOCK_DOWNLOAD_FILE" "$output" +SH + +cat >"$direct_bin/tar" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'tar %s\n' "$*" >>"$MOCK_COMMAND_LOG" +destination="" +while [[ $# -gt 0 ]]; do + case "$1" in + -C) + destination=$2 + shift 2 + ;; + *) shift ;; + esac +done +[[ -n "$destination" ]] +root="$destination/wasmtime-v46.0.1-x86_64-linux" +mkdir -p "$root" +cat >"$root/wasmtime" <<'EOF' +#!/usr/bin/env bash +printf 'wasmtime 46.0.1 (mock)\n' +printf 'wasmtime executed\n' >>"$MOCK_EXEC_LOG" +EOF +chmod +x "$root/wasmtime" +SH + +cat >"$direct_bin/cargo" <<'SH' +#!/usr/bin/env bash +printf 'cargo %s\n' "$*" >>"$MOCK_COMMAND_LOG" +SH + +cat >"$direct_bin/rustup" <<'SH' +#!/usr/bin/env bash +printf 'rustup %s\n' "$*" >>"$MOCK_COMMAND_LOG" +SH +chmod +x "$direct_bin"/* + +codecov_artifact="$TMP_ROOT/codecov" +cat >"$codecov_artifact" <<'SH' +#!/usr/bin/env bash +printf 'codecovcli version 11.3.1\n' +printf 'codecov executed\n' >>"$MOCK_EXEC_LOG" +SH +codecov_digest=$(sha256_file "$codecov_artifact") + +direct_fixture="$TMP_ROOT/direct-fixture" +make_direct_fixture "$direct_fixture" +set_manifest_digest "$direct_fixture" codecov linux x86_64 "$codecov_digest" + +codecov_temp="$TMP_ROOT/codecov-valid" +codecov_output="$TMP_ROOT/codecov.output" +codecov_exec="$TMP_ROOT/codecov.exec" +mkdir -p "$codecov_temp" +PATH="$direct_bin:$PATH" \ + RUNNER_TEMP="$codecov_temp" \ + GITHUB_OUTPUT="$codecov_output" \ + MOCK_COMMAND_LOG="$direct_log" \ + MOCK_DOWNLOAD_FILE="$codecov_artifact" \ + MOCK_EXEC_LOG="$codecov_exec" \ + "$direct_fixture/scripts/ci/install-codecov.sh" +installed_codecov=$(sed -n 's/^binary=//p' "$codecov_output") +[[ -x "$installed_codecov" ]] || fail "valid Codecov artifact was not installed" +grep -Fqx 'codecov executed' "$codecov_exec" \ + || fail "verified Codecov artifact did not execute" + +invalid_artifact="$TMP_ROOT/invalid-codecov" +cat >"$invalid_artifact" <<'SH' +#!/usr/bin/env bash +printf 'invalid artifact executed\n' >>"$MOCK_EXEC_LOG" +printf 'codecovcli version 11.3.1\n' +SH +invalid_temp="$TMP_ROOT/codecov-invalid" +invalid_exec="$TMP_ROOT/codecov-invalid.exec" +mkdir -p "$invalid_temp" +if PATH="$direct_bin:$PATH" \ + RUNNER_TEMP="$invalid_temp" \ + GITHUB_OUTPUT="$TMP_ROOT/codecov-invalid.output" \ + MOCK_COMMAND_LOG="$direct_log" \ + MOCK_DOWNLOAD_FILE="$invalid_artifact" \ + MOCK_EXEC_LOG="$invalid_exec" \ + "$direct_fixture/scripts/ci/install-codecov.sh" >/dev/null 2>&1; then + fail "checksum mismatch installed Codecov" +fi +[[ ! -e "$invalid_exec" ]] || fail "checksum mismatch executed Codecov" +[[ ! -s "$TMP_ROOT/codecov-invalid.output" ]] \ + || fail "checksum mismatch published an installed Codecov path" + +missing_temp="$TMP_ROOT/codecov-missing" +mkdir -p "$missing_temp" +if PATH="$direct_bin:$PATH" \ + RUNNER_TEMP="$missing_temp" \ + GITHUB_OUTPUT="$TMP_ROOT/codecov-missing.output" \ + MOCK_COMMAND_LOG="$direct_log" \ + MOCK_DOWNLOAD_FILE="$codecov_artifact" \ + MOCK_CURL_FAIL=1 \ + MOCK_EXEC_LOG="$TMP_ROOT/codecov-missing.exec" \ + "$direct_fixture/scripts/ci/install-codecov.sh" >/dev/null 2>&1; then + fail "missing download was accepted" +fi +[[ ! -e "$TMP_ROOT/codecov-missing.exec" ]] \ + || fail "missing download reached executable fallback" + +: >"$direct_log" +unsupported_temp="$TMP_ROOT/codecov-unsupported" +mkdir -p "$unsupported_temp" +if PATH="$direct_bin:$PATH" \ + RUNNER_TEMP="$unsupported_temp" \ + GITHUB_OUTPUT="$TMP_ROOT/codecov-unsupported.output" \ + MOCK_COMMAND_LOG="$direct_log" \ + MOCK_DOWNLOAD_FILE="$codecov_artifact" \ + MOCK_UNAME_M=riscv64 \ + MOCK_EXEC_LOG="$TMP_ROOT/codecov-unsupported.exec" \ + "$direct_fixture/scripts/ci/install-codecov.sh" >/dev/null 2>&1; then + fail "unsupported direct-tool architecture was accepted" +fi +[[ ! -s "$direct_log" ]] || fail "unsupported architecture attempted a download" + +missing_platform_fixture="$TMP_ROOT/missing-platform-fixture" +make_direct_fixture "$missing_platform_fixture" +awk -F '\t' '$1 != "codecov"' \ + "$missing_platform_fixture/.config/ci-tool-archives.tsv" \ + >"$missing_platform_fixture/.config/ci-tool-archives.tsv.tmp" +mv "$missing_platform_fixture/.config/ci-tool-archives.tsv.tmp" \ + "$missing_platform_fixture/.config/ci-tool-archives.tsv" +: >"$direct_log" +mkdir -p "$TMP_ROOT/codecov-no-platform" +if PATH="$direct_bin:$PATH" \ + RUNNER_TEMP="$TMP_ROOT/codecov-no-platform" \ + GITHUB_OUTPUT="$TMP_ROOT/codecov-no-platform.output" \ + MOCK_COMMAND_LOG="$direct_log" \ + MOCK_DOWNLOAD_FILE="$codecov_artifact" \ + MOCK_EXEC_LOG="$TMP_ROOT/codecov-no-platform.exec" \ + "$missing_platform_fixture/scripts/ci/install-codecov.sh" >/dev/null 2>&1; then + fail "missing platform contract was accepted" +fi +[[ ! -s "$direct_log" ]] || fail "missing platform contract attempted a download" + +wasmtime_artifact="$TMP_ROOT/wasmtime.tar.xz" +printf 'authenticated mock Wasmtime archive\n' >"$wasmtime_artifact" +wasmtime_digest=$(sha256_file "$wasmtime_artifact") +set_manifest_digest "$direct_fixture" wasmtime linux x86_64 "$wasmtime_digest" +wasmtime_home="$TMP_ROOT/wasmtime-home" +wasmtime_exec="$TMP_ROOT/wasmtime.exec" +: >"$direct_log" +( + cd "$direct_fixture" + PATH="$direct_bin:$PATH" \ + WASMTIME_HOME="$wasmtime_home" \ + MOCK_COMMAND_LOG="$direct_log" \ + MOCK_DOWNLOAD_FILE="$wasmtime_artifact" \ + MOCK_EXEC_LOG="$wasmtime_exec" \ + scripts/ci/nostd-wasm-suite.sh wasm32-wasip1 shallow +) >/dev/null +[[ -x "$wasmtime_home/bin/wasmtime" ]] || fail "verified Wasmtime was not installed" +grep -Fq 'tar -xJf' "$direct_log" || fail "verified Wasmtime was not extracted" +grep -Fqx 'wasmtime executed' "$wasmtime_exec" || fail "verified Wasmtime was not executed" + +: >"$direct_log" +bad_wasmtime_exec="$TMP_ROOT/bad-wasmtime.exec" +if ( + cd "$direct_fixture" + PATH="$direct_bin:$PATH" \ + WASMTIME_HOME="$TMP_ROOT/bad-wasmtime-home" \ + MOCK_COMMAND_LOG="$direct_log" \ + MOCK_DOWNLOAD_FILE="$invalid_artifact" \ + MOCK_EXEC_LOG="$bad_wasmtime_exec" \ + scripts/ci/nostd-wasm-suite.sh wasm32-wasip1 shallow +) >/dev/null 2>&1; then + fail "invalid Wasmtime archive was accepted" +fi +if grep -Fq 'tar ' "$direct_log"; then + fail "invalid Wasmtime archive reached extraction" +fi +[[ ! -e "$bad_wasmtime_exec" ]] || fail "invalid Wasmtime archive reached execution" + +zig_artifact="$TMP_ROOT/zig.tar.xz" +printf 'authenticated mock Zig archive\n' >"$zig_artifact" +zig_digest=$(sha256_file "$zig_artifact") +set_manifest_digest "$direct_fixture" zig linux x86_64 "$zig_digest" +zig_download="$TMP_ROOT/zig-download" +mkdir -p "$zig_download" +( + cd "$direct_fixture" + export PATH="$direct_bin:$PATH" + export MOCK_COMMAND_LOG="$direct_log" + export MOCK_DOWNLOAD_FILE="$zig_artifact" + source scripts/lib/ci-tool-integrity.sh + ci_tool_download zig "$zig_download" +) +cmp "$zig_artifact" \ + "$zig_download/zig-x86_64-linux-0.17.0-dev.1282+c0f9b51d8.tar.xz" \ + || fail "verified Zig artifact did not match the authenticated download" + +package_bin="$TMP_ROOT/package-bin" +package_log="$TMP_ROOT/package.log" +package_state="$TMP_ROOT/package.state" +mkdir -p "$package_bin" +: >"$package_state" + +cat >"$package_bin/cargo" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'cargo %s\n' "$*" >>"$MOCK_PACKAGE_LOG" +if [[ "${1:-}" == install && "${2:-}" == --list ]]; then + while read -r package version; do + [[ -n "$package" ]] || continue + printf '%s v%s:\n %s\n' "$package" "$version" "$package" + done <"$MOCK_CARGO_STATE" + exit 0 +fi +[[ "${1:-}" == install ]] || exit 0 +shift +package="" +required="" +while [[ $# -gt 0 ]]; do + case "$1" in + --registry) + [[ "$2" == crates-io ]] + shift 2 + ;; + --version) + required=$2 + shift 2 + ;; + --locked | --force) shift ;; + -*) exit 91 ;; + *) + [[ -z "$package" ]] || exit 92 + package=$1 + shift + ;; + esac +done +[[ -n "$package" && "$required" == =* ]] +case "$CARGO_HOME" in + "$RUNNER_TEMP"/rscrypto-ci-tools.*/cargo) ;; + *) exit 93 ;; +esac +version=${required#=} +awk -v package="$package" '$1 != package' "$MOCK_CARGO_STATE" >"$MOCK_CARGO_STATE.tmp" +printf '%s %s\n' "$package" "$version" >>"$MOCK_CARGO_STATE.tmp" +mv "$MOCK_CARGO_STATE.tmp" "$MOCK_CARGO_STATE" +binary="$CARGO_HOME/bin/$package" +mkdir -p "$(dirname "$binary")" +printf '#!/usr/bin/env bash\nprintf "%%s %%s\\n" %q %q\n' "$package" "$version" >"$binary" +chmod +x "$binary" +SH + +cat >"$package_bin/go" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'go %s\n' "$*" >>"$MOCK_PACKAGE_LOG" +[[ "$1" == install && "$2" == github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 ]] +case "$GOMODCACHE" in + "$RUNNER_TEMP"/rscrypto-ci-tools.*/go/pkg/mod) ;; + *) exit 93 ;; +esac +case "$GOCACHE" in + "$RUNNER_TEMP"/rscrypto-ci-tools.*/go-build) ;; + *) exit 94 ;; +esac +mkdir -p "$GOBIN" +cat >"$GOBIN/actionlint" <<'EOF' +#!/usr/bin/env bash +printf '1.7.12\n' +EOF +chmod +x "$GOBIN/actionlint" +SH + +cat >"$package_bin/rustup" <<'SH' +#!/usr/bin/env bash +printf 'rustup %s\n' "$*" >>"$MOCK_PACKAGE_LOG" +SH + +cat >"$package_bin/rustc" <<'SH' +#!/usr/bin/env bash +printf 'rustc 1.97.0-nightly\ncommit-date: 2026-04-26\n' +SH + +cp "$direct_bin/uname" "$package_bin/uname" +chmod +x "$package_bin"/* + +package_home="$TMP_ROOT/package-home" +package_temp="$TMP_ROOT/package-temp" +mkdir -p "$package_home/.cargo/bin" +mkdir -p "$package_temp" +: >"$package_log" +for mode in standard quality release rail ci supply-chain ibm bench fuzz coverage minimal none; do + HOME="$package_home" \ + RUNNER_TEMP="$package_temp" \ + PATH="$package_bin:$PATH" \ + MOCK_PACKAGE_LOG="$package_log" \ + MOCK_CARGO_STATE="$package_state" \ + "$REPO_ROOT/scripts/ci/install-tools.sh" "$mode" >/dev/null +done + +cat >"$package_bin/sudo" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +"$@" +SH + +cat >"$package_bin/apt-get" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'apt-get %s\n' "$*" >>"$MOCK_PACKAGE_LOG" +SH + +cat >"$package_bin/dpkg-query" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +case "${*: -1}" in + build-essential) printf '12.10ubuntu1' ;; + git) printf '1:2.43.0-1ubuntu7.3' ;; + libgmp-dev) printf '2:6.3.0+dfsg-2ubuntu6' ;; + libmpfr-dev) printf '4.2.1-1build1' ;; + m4) printf '1.4.19-4build1' ;; + opam) printf '2.1.5-1' ;; + pkg-config) printf '1.8.1-2build1' ;; + zlib1g-dev) printf '1:1.3.dfsg-3.1ubuntu2' ;; + musl-tools) printf '1.2.4-2' ;; + *) exit 96 ;; +esac +SH + +cat >"$package_bin/git" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'git %s\n' "$*" >>"$MOCK_PACKAGE_LOG" +[[ "$1" == -C && "$3" == rev-parse && "$4" == HEAD ]] +printf '49f6d620cf20ae0168cfcbeb2c33932e06cb4b74\n' +SH + +cat >"$package_bin/opam" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'opamroot=%s opam %s\n' "$OPAMROOT" "$*" >>"$MOCK_PACKAGE_LOG" +case "$1" in + init) + mkdir -p "$OPAMROOT/repo/default/.git" + ;; + switch) + [[ "$2" == create ]] + ;; + install) + mkdir -p "$OPAMROOT/$OPAMSWITCH/bin" + cat >"$OPAMROOT/$OPAMSWITCH/bin/binsec" <<'EOF' +#!/usr/bin/env bash +printf 'BINSEC version 0.11.1\n' +EOF + chmod +x "$OPAMROOT/$OPAMSWITCH/bin/binsec" + ;; + reinstall) ;; + list) + printf '%s\n' \ + ocaml-base-compiler.5.2.1 \ + unisim_archisec.0.0.14 \ + bitwuzla.1.0.6 \ + bitwuzla-cxx.0.9.0 \ + binsec.0.11.1 + ;; + var) + [[ "$2" == bin ]] + printf '%s\n' "$OPAMROOT/$OPAMSWITCH/bin" + ;; + *) exit 97 ;; +esac +SH +chmod +x "$package_bin/sudo" "$package_bin/apt-get" \ + "$package_bin/dpkg-query" "$package_bin/git" "$package_bin/opam" + +ct_home="$TMP_ROOT/ct-home" +ct_temp="$TMP_ROOT/ct-temp" +ct_state="$TMP_ROOT/ct.state" +ct_log="$TMP_ROOT/ct.log" +ct_os_release="$TMP_ROOT/ct-os-release" +ct_installer="$TMP_ROOT/install-tools-ct.sh" +mkdir -p "$ct_home" "$ct_temp" +: >"$ct_state" +: >"$ct_log" +printf 'ID=ubuntu\nVERSION_ID="24.04"\n' >"$ct_os_release" +cp "$REPO_ROOT/scripts/ci/install-tools.sh" "$ct_installer" +sed -i.bak "s#/etc/os-release#$ct_os_release#g" "$ct_installer" +rm -f "$ct_installer.bak" +HOME="$ct_home" \ + RUNNER_TEMP="$ct_temp" \ + PATH="$package_bin:$PATH" \ + MOCK_PACKAGE_LOG="$ct_log" \ + MOCK_CARGO_STATE="$ct_state" \ + "$ct_installer" ct-linux >/dev/null +grep -Fq \ + 'apt-get install -y --no-install-recommends --allow-downgrades build-essential=12.10ubuntu1 git=1:2.43.0-1ubuntu7.3' \ + "$ct_log" || fail "ct-linux did not select exact Ubuntu package versions" +grep -Fq \ + 'opam init --bare --disable-sandboxing --no-setup --no-opamrc -y default git+https://github.com/ocaml/opam-repository.git#49f6d620cf20ae0168cfcbeb2c33932e06cb4b74' \ + "$ct_log" || fail "ct-linux did not select the commit-pinned OPAM repository" +grep -Eq '^opamroot=.*/ct-temp/rscrypto-ci-tools\.[^/]+/opam opam switch create rscrypto-ct ocaml-base-compiler\.5\.2\.1 ' \ + "$ct_log" || fail "ct-linux did not use a fresh exact OPAM switch" + +for contract in \ + 'cargo-nextest =0.9.140' \ + 'cargo-deny =0.20.2' \ + 'cargo-audit =0.22.2' \ + 'cargo-rail =0.18.0' \ + 'cargo-semver-checks =0.48.0' \ + 'just =1.57.0' \ + 'zizmor =1.26.1' \ + 'cargo-criterion =1.1.0' \ + 'critcmp =0.1.8' \ + 'cargo-fuzz =0.13.2' \ + 'cargo-llvm-cov =0.8.7'; do + package=${contract%% *} + version=${contract#* } + grep -Fq "cargo install --registry crates-io $package --locked --version $version --force" \ + "$package_log" || fail "$package was not installed through its exact Cargo contract" +done +grep -Fq 'go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12' "$package_log" \ + || fail "actionlint was not installed at an exact Go module version" +grep -Fq 'rustup component add llvm-tools-preview' "$package_log" \ + || fail "coverage did not use the pinned rustup toolchain boundary" +if grep -Eq 'binstall|latest' "$package_log"; then + fail "tool mode selected a mutable or Cargo-binstall path" +fi + +cached_home="$TMP_ROOT/cached-home" +cached_state="$TMP_ROOT/cached.state" +malicious_exec="$TMP_ROOT/malicious.exec" +github_path_file="$TMP_ROOT/github.path" +mkdir -p "$cached_home/.cargo/bin" +: >"$cached_state" +: >"$github_path_file" +cat >"$cached_home/.cargo/bin/just" <<'SH' +#!/usr/bin/env bash +printf 'forged exact-version cache executed\n' >>"$MALICIOUS_EXEC_LOG" +printf 'just 1.57.0\n' +SH +chmod +x "$cached_home/.cargo/bin/just" +cat >"$cached_home/.cargo/.crates.toml" <<'EOF' +[v1] +"just 1.57.0 (registry+https://github.com/rust-lang/crates.io-index)" = ["just"] +EOF +: >"$package_log" +HOME="$cached_home" \ + RUNNER_TEMP="$package_temp" \ + GITHUB_PATH="$github_path_file" \ + PATH="$package_bin:$PATH" \ + MOCK_PACKAGE_LOG="$package_log" \ + MOCK_CARGO_STATE="$cached_state" \ + MALICIOUS_EXEC_LOG="$malicious_exec" \ + "$REPO_ROOT/scripts/ci/install-tools.sh" minimal >/dev/null +[[ ! -e "$malicious_exec" ]] \ + || fail "forged exact-version cached binary executed before authenticated replacement" +grep -Fq 'cargo install --registry crates-io just --locked --version =1.57.0 --force' "$package_log" \ + || fail "forged cache did not trigger a fresh authenticated install" +trusted_bin=$(tail -n 1 "$github_path_file") +case "$trusted_bin" in + "$package_temp"/rscrypto-ci-tools.*/cargo/bin) ;; + *) fail "authenticated Cargo tool root was not exported to later CI steps" ;; +esac + +: >"$package_log" +MOCK_PACKAGE_LOG="$package_log" PATH="$package_bin:$PATH" \ + "$REPO_ROOT/scripts/ci/setup-toolchain.sh" \ + nightly-2026-04-27 'clippy, rustfmt' >/dev/null +grep -Fq \ + 'rustup toolchain install nightly-2026-04-27 --profile minimal --no-self-update --component clippy --component rustfmt' \ + "$package_log" || fail "rustup toolchain command was not exact" +grep -Fq 'rustup default nightly-2026-04-27' "$package_log" \ + || fail "rustup did not select the exact toolchain" +if MOCK_PACKAGE_LOG="$package_log" PATH="$package_bin:$PATH" \ + "$REPO_ROOT/scripts/ci/setup-toolchain.sh" nightly clippy >/dev/null 2>&1; then + fail "mutable rustup channel was accepted" +fi + +echo "CI tool integrity regression tests passed" diff --git a/scripts/lib/ci-tool-integrity.sh b/scripts/lib/ci-tool-integrity.sh new file mode 100644 index 00000000..a5754b1f --- /dev/null +++ b/scripts/lib/ci-tool-integrity.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash + +CI_TOOL_INTEGRITY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CI_TOOL_REPO_ROOT="$(cd "$CI_TOOL_INTEGRITY_DIR/../.." && pwd)" +CI_TOOL_ARCHIVES="$CI_TOOL_REPO_ROOT/.config/ci-tool-archives.tsv" + +ci_tool_fail() { + echo "CI tool integrity error: $*" >&2 + return 1 +} + +ci_tool_detect_host() { + case "$(uname -s)" in + Linux) CI_TOOL_HOST_OS=linux ;; + Darwin) CI_TOOL_HOST_OS=macos ;; + *) ci_tool_fail "unsupported host OS: $(uname -s)" || return ;; + esac + + case "$(uname -m)" in + x86_64 | amd64) CI_TOOL_HOST_ARCH=x86_64 ;; + aarch64 | arm64) CI_TOOL_HOST_ARCH=aarch64 ;; + *) ci_tool_fail "unsupported host architecture: $(uname -m)" || return ;; + esac +} + +ci_tool_validate_record() { + local tool=$1 + local version=$2 + local os=$3 + local architecture=$4 + local filename=$5 + local url=$6 + local digest=$7 + + case "$tool" in + wasmtime | zig | codecov) ;; + *) ci_tool_fail "unknown direct CI tool: $tool" || return ;; + esac + [[ "$version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+[-+A-Za-z0-9.]*$ ]] \ + || ci_tool_fail "invalid version for $tool: $version" || return + case "$version" in + *[Ll][Aa][Tt][Ee][Ss][Tt]*) ci_tool_fail "mutable version for $tool: $version" || return ;; + esac + [[ "$os" == linux || "$os" == macos ]] \ + || ci_tool_fail "invalid host OS for $tool: $os" || return + [[ "$architecture" == x86_64 || "$architecture" == aarch64 ]] \ + || ci_tool_fail "invalid host architecture for $tool: $architecture" || return + [[ "$filename" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] \ + || ci_tool_fail "invalid filename for $tool: $filename" || return + [[ "$url" == https://* ]] \ + || ci_tool_fail "invalid URL for $tool: $url" || return + case "$url" in + *[Ll][Aa][Tt][Ee][Ss][Tt]*) ci_tool_fail "mutable URL for $tool: $url" || return ;; + esac + [[ "$url" != *\?* && "$url" != *\#* && "${url##*/}" == "$filename" ]] \ + || ci_tool_fail "URL and filename disagree for $tool" || return + [[ "$url" == *"$version"* ]] \ + || ci_tool_fail "URL does not contain the exact $tool version" || return + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] \ + || ci_tool_fail "invalid SHA-256 for $tool $os/$architecture" || return +} + +ci_tool_resolve() { + local requested_tool=$1 + local entry_tool entry_version entry_os entry_arch entry_filename entry_url entry_digest extra + local selector seen_selectors=$'\n' + local matches=0 + + [[ -f "$CI_TOOL_ARCHIVES" ]] \ + || ci_tool_fail "missing archive manifest: $CI_TOOL_ARCHIVES" || return + ci_tool_detect_host || return + + while IFS=$'\t' read -r \ + entry_tool entry_version entry_os entry_arch entry_filename entry_url entry_digest extra \ + || [[ -n "$entry_tool$entry_version$entry_os$entry_arch$entry_filename$entry_url$entry_digest$extra" ]]; do + [[ -z "$entry_tool" || "$entry_tool" == \#* ]] && continue + [[ -z "$extra" ]] || ci_tool_fail "unexpected archive manifest field for $entry_tool" || return + ci_tool_validate_record \ + "$entry_tool" "$entry_version" "$entry_os" "$entry_arch" \ + "$entry_filename" "$entry_url" "$entry_digest" || return + + selector="$entry_tool/$entry_os/$entry_arch" + case "$seen_selectors" in + *$'\n'"$selector"$'\n'*) ci_tool_fail "duplicate archive contract: $selector" || return ;; + esac + seen_selectors+="$selector"$'\n' + + if [[ "$entry_tool" == "$requested_tool" \ + && "$entry_os" == "$CI_TOOL_HOST_OS" \ + && "$entry_arch" == "$CI_TOOL_HOST_ARCH" ]]; then + matches=$((matches + 1)) + CI_TOOL_NAME=$entry_tool + CI_TOOL_VERSION=$entry_version + CI_TOOL_FILENAME=$entry_filename + CI_TOOL_URL=$entry_url + CI_TOOL_SHA256=$entry_digest + fi + done <"$CI_TOOL_ARCHIVES" + + [[ "$matches" -eq 1 ]] \ + || ci_tool_fail "no unique $requested_tool archive for $CI_TOOL_HOST_OS/$CI_TOOL_HOST_ARCH" || return +} + +ci_tool_validate_manifest() { + local tool + for tool in wasmtime zig codecov; do + case "$tool" in + wasmtime) + local expected_platforms=(linux:x86_64 linux:aarch64 macos:x86_64 macos:aarch64) + ;; + zig | codecov) + local expected_platforms=(linux:x86_64) + ;; + esac + + local platform + for platform in "${expected_platforms[@]}"; do + local expected_os=${platform%%:*} + local expected_arch=${platform#*:} + local count + count=$(awk -F '\t' \ + -v tool="$tool" -v os="$expected_os" -v arch="$expected_arch" \ + '$1 == tool && $3 == os && $4 == arch { count++ } END { print count + 0 }' \ + "$CI_TOOL_ARCHIVES") + [[ "$count" -eq 1 ]] \ + || ci_tool_fail "expected one $tool archive for $expected_os/$expected_arch" || return + done + done + + local entry_tool entry_version entry_os entry_arch entry_filename entry_url entry_digest extra + local selector seen_selectors=$'\n' + while IFS=$'\t' read -r \ + entry_tool entry_version entry_os entry_arch entry_filename entry_url entry_digest extra \ + || [[ -n "$entry_tool$entry_version$entry_os$entry_arch$entry_filename$entry_url$entry_digest$extra" ]]; do + [[ -z "$entry_tool" || "$entry_tool" == \#* ]] && continue + [[ -z "$extra" ]] || ci_tool_fail "unexpected archive manifest field for $entry_tool" || return + ci_tool_validate_record \ + "$entry_tool" "$entry_version" "$entry_os" "$entry_arch" \ + "$entry_filename" "$entry_url" "$entry_digest" || return + selector="$entry_tool/$entry_os/$entry_arch" + case "$seen_selectors" in + *$'\n'"$selector"$'\n'*) ci_tool_fail "duplicate archive contract: $selector" || return ;; + esac + seen_selectors+="$selector"$'\n' + done <"$CI_TOOL_ARCHIVES" +} + +ci_tool_sha256() { + local path=$1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + else + ci_tool_fail "sha256sum or shasum is required" + fi +} + +ci_tool_download() { + local tool=$1 + local destination_dir=$2 + local partial actual + + ci_tool_resolve "$tool" || return + [[ -d "$destination_dir" ]] \ + || ci_tool_fail "download destination is not a directory: $destination_dir" || return + + CI_TOOL_ARCHIVE_PATH="$destination_dir/$CI_TOOL_FILENAME" + [[ ! -e "$CI_TOOL_ARCHIVE_PATH" ]] \ + || ci_tool_fail "refusing to overwrite archive: $CI_TOOL_ARCHIVE_PATH" || return + partial=$(mktemp "$destination_dir/.${CI_TOOL_FILENAME}.part.XXXXXX") + + if ! curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \ + --retry 3 --retry-delay 2 --output "$partial" "$CI_TOOL_URL"; then + ci_tool_fail "download failed for $CI_TOOL_NAME $CI_TOOL_VERSION" || return + fi + + actual=$(ci_tool_sha256 "$partial") || return + if [[ "$actual" != "$CI_TOOL_SHA256" ]]; then + ci_tool_fail \ + "SHA-256 mismatch for $CI_TOOL_FILENAME (expected $CI_TOOL_SHA256, got $actual)" || return + fi + + mv "$partial" "$CI_TOOL_ARCHIVE_PATH" +}