diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 299768ffda..fea966265a 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -124,6 +124,12 @@ jobs: - name: Run host-target CLI clippy run: cargo clippy --package trusted-server-cli --target x86_64-unknown-linux-gnu --all-targets --all-features -- -D warnings + - name: Run host-target release producer clippy + run: cargo clippy --package trusted-server-release --target x86_64-unknown-linux-gnu --all-targets --all-features --locked -- -D warnings + + - name: Lint native JS release build fixtures + run: cargo clippy --package trusted-server-js --target x86_64-unknown-linux-gnu --all-targets --all-features --locked -- -D warnings + # The codegen crate is a workspace member but is not a default member and # is not covered by any adapter-scoped alias, so lint it explicitly. - name: Run host-target OpenRTB codegen clippy @@ -247,7 +253,7 @@ jobs: node-version: ${{ steps.node-version.outputs.node-version }} - name: Run shellcheck - run: shellcheck scripts/release/phase1-build.sh + run: shellcheck scripts/release/phase1-build.sh scripts/release/validate-fastly-package.sh scripts/release/bootstrap.sh scripts/release/build.sh scripts/release/build-js.sh - name: Test release producer scripts run: node --test scripts/release/phase1.test.mjs diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 309198d73a..432fc7a666 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -8,6 +8,8 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read + outputs: + node: ${{ steps.tools.outputs.node }} steps: # Full history, so the branch check below can see every branch. - uses: actions/checkout@v5 @@ -56,6 +58,156 @@ jobs: exit 1 fi printf 'Tagged commit is on:\n%s\n' "$branches" + fastly-canonical: + needs: checks + name: Build trusted-server-fastly.wasm + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: ordinary-canonical-source + outputs: + artifact-id: ${{ steps.artifact.outputs.artifact-id }} + artifact-digest: ${{ steps.artifact.outputs.artifact-digest }} + observation-id: ${{ steps.observation.outputs.artifact-id }} + observation-digest: ${{ steps.observation.outputs.artifact-digest }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + ref: ${{ github.sha }} + path: ordinary-canonical-source + - name: Read pinned toolchains + id: tools + run: | + set -euo pipefail + echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "rust=$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v5 + with: + node-version: ${{ steps.tools.outputs.node }} + - name: Install the pinned Rust toolchain without build caches + env: + RUST_VERSION: ${{ steps.tools.outputs.rust }} + run: rustup toolchain install "$RUST_VERSION" --profile minimal --target wasm32-wasip1 + - name: Build the ordinary measurement tool separately + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/ordinary-producer-target + run: | + set -euo pipefail + test ! -e "$CARGO_TARGET_DIR" + cargo build --package trusted-server-release --release --target x86_64-unknown-linux-gnu --locked + - name: Build and observe the actual canonical Fastly release bytes + env: + SOURCE_COMMIT: ${{ github.sha }} + RELEASE_ASSET: trusted-server-fastly.wasm + run: | + set -euo pipefail + env -i PATH="$PATH" HOME="$HOME" \ + RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" \ + TMPDIR="${TMPDIR:-/tmp}" RUNNER_TEMP="$RUNNER_TEMP" \ + SOURCE_COMMIT="$SOURCE_COMMIT" \ + ORDINARY_RELEASE_PRODUCER="$RUNNER_TEMP/ordinary-producer-target/x86_64-unknown-linux-gnu/release/trusted-server-release" \ + bash scripts/release/phase1-build.sh trusted-server-adapter-fastly wasm32-wasip1 "$RELEASE_ASSET" canonical + - name: Verify the pinned manual Fastly route offline + timeout-minutes: 5 + run: bash scripts/release/validate-fastly-package.sh + - name: Upload canonical release artifact + id: artifact + uses: actions/upload-artifact@v6 + with: + name: release-trusted-server-fastly.wasm + path: ordinary-canonical-source/release-assets/* + if-no-files-found: error + retention-days: 7 + - name: Upload canonical observation + id: observation + uses: actions/upload-artifact@v6 + with: + name: fastly-observation-canonical + path: ${{ runner.temp }}/fastly-observation/* + if-no-files-found: error + retention-days: 7 + + fastly-reproduction: + # Deliberately after canonical: the comparison rejects a pair whose + # whole-second start times match, and parallel jobs can start together. + needs: [checks, fastly-canonical] + name: Reproduce trusted-server-fastly.wasm + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: ordinary-reproduction-source + outputs: + artifact-id: ${{ steps.artifact.outputs.artifact-id }} + artifact-digest: ${{ steps.artifact.outputs.artifact-digest }} + observation-id: ${{ steps.observation.outputs.artifact-id }} + observation-digest: ${{ steps.observation.outputs.artifact-digest }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + ref: ${{ github.sha }} + path: ordinary-reproduction-source + - name: Read pinned toolchains + id: tools + run: | + set -euo pipefail + echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "rust=$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v5 + with: + node-version: ${{ steps.tools.outputs.node }} + - name: Install the pinned Rust toolchain without build caches + env: + RUST_VERSION: ${{ steps.tools.outputs.rust }} + run: rustup toolchain install "$RUST_VERSION" --profile minimal --target wasm32-wasip1 + - name: Build the ordinary measurement tool separately + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/ordinary-producer-target + run: | + set -euo pipefail + test ! -e "$CARGO_TARGET_DIR" + cargo build --package trusted-server-release --release --target x86_64-unknown-linux-gnu --locked + - name: Build and observe the actual reproduction Fastly release bytes + env: + SOURCE_COMMIT: ${{ github.sha }} + RELEASE_ASSET: trusted-server-fastly.wasm + run: | + set -euo pipefail + env -i PATH="$PATH" HOME="$HOME" \ + RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" \ + TMPDIR="${TMPDIR:-/tmp}" RUNNER_TEMP="$RUNNER_TEMP" \ + SOURCE_COMMIT="$SOURCE_COMMIT" \ + ORDINARY_RELEASE_PRODUCER="$RUNNER_TEMP/ordinary-producer-target/x86_64-unknown-linux-gnu/release/trusted-server-release" \ + bash scripts/release/phase1-build.sh trusted-server-adapter-fastly wasm32-wasip1 "$RELEASE_ASSET" reproduction + - name: Verify the pinned manual Fastly route offline + timeout-minutes: 5 + run: bash scripts/release/validate-fastly-package.sh + - name: Upload reproduction release artifact + id: artifact + uses: actions/upload-artifact@v6 + with: + name: reproduction-trusted-server-fastly.wasm + path: ordinary-reproduction-source/release-assets/* + if-no-files-found: error + retention-days: 7 + - name: Upload reproduction observation + id: observation + uses: actions/upload-artifact@v6 + with: + name: fastly-observation-reproduction + path: ${{ runner.temp }}/fastly-observation/* + if-no-files-found: error + retention-days: 7 + build: needs: checks name: Build ${{ matrix.asset }} @@ -67,10 +219,6 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-24.04 - package: trusted-server-adapter-fastly - target: wasm32-wasip1 - asset: trusted-server-fastly.wasm - os: ubuntu-24.04 package: trusted-server-cli target: x86_64-unknown-linux-gnu @@ -127,9 +275,137 @@ jobs: if-no-files-found: error retention-days: 7 + fastly-comparison: + needs: [fastly-canonical, fastly-reproduction] + name: Compare ordinary Fastly release bytes + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Read pinned toolchains + id: tools + run: | + set -euo pipefail + echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "rust=$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v5 + with: + node-version: ${{ steps.tools.outputs.node }} + - name: Install the pinned Rust toolchain + env: + RUST_VERSION: ${{ steps.tools.outputs.rust }} + run: rustup toolchain install "$RUST_VERSION" --profile minimal + - name: Build the ordinary comparison tool from the expected source + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/ordinary-compare-tool + SOURCE_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" + test ! -e "$CARGO_TARGET_DIR" + cargo build --package trusted-server-release --release --target x86_64-unknown-linux-gnu --locked + - name: Retain the invocation and all eight direct upload outputs + env: + SOURCE_COMMIT: ${{ github.sha }} + REPOSITORY_ID: ${{ github.repository_id }} + # Expected value, not a self-check: the comparator matches it against + # each artifact's API-reported workflow_run.head_repository_id. This + # workflow only runs on push and tag events, whose head repository is + # always this repository, so a fork-headed run's uploads are rejected. + HEAD_REPOSITORY_ID: ${{ github.repository_id }} + A_ARTIFACT_ID: ${{ needs.fastly-canonical.outputs.artifact-id }} + A_ARTIFACT_DIGEST: ${{ needs.fastly-canonical.outputs.artifact-digest }} + A_OBSERVATION_ID: ${{ needs.fastly-canonical.outputs.observation-id }} + A_OBSERVATION_DIGEST: ${{ needs.fastly-canonical.outputs.observation-digest }} + B_ARTIFACT_ID: ${{ needs.fastly-reproduction.outputs.artifact-id }} + B_ARTIFACT_DIGEST: ${{ needs.fastly-reproduction.outputs.artifact-digest }} + B_OBSERVATION_ID: ${{ needs.fastly-reproduction.outputs.observation-id }} + B_OBSERVATION_DIGEST: ${{ needs.fastly-reproduction.outputs.observation-digest }} + run: | + set -euo pipefail + node <<'JAVASCRIPT' + const fs = require('node:fs'), path = require('node:path'), e = process.env; + const directory = path.join(e.RUNNER_TEMP, 'ordinary-inputs'); + fs.mkdirSync(directory, { mode: 0o700 }); + const upload = prefix => ({ + artifact_id: Number(e[`${prefix}_ARTIFACT_ID`]), + artifact_digest: e[`${prefix}_ARTIFACT_DIGEST`], + observation_id: Number(e[`${prefix}_OBSERVATION_ID`]), + observation_digest: e[`${prefix}_OBSERVATION_DIGEST`], + }); + fs.writeFileSync(path.join(directory, 'context.json'), JSON.stringify({ + repository: e.GITHUB_REPOSITORY, repository_id: Number(e.REPOSITORY_ID), + head_repository_id: Number(e.HEAD_REPOSITORY_ID), source_commit: e.SOURCE_COMMIT, + run_id: Number(e.GITHUB_RUN_ID), run_attempt: Number(e.GITHUB_RUN_ATTEMPT), + canonical: upload('A'), reproduction: upload('B'), + }) + '\n', { flag: 'wx', mode: 0o600 }); + JAVASCRIPT + - name: Acquire only current-run bound archives through read-only APIs + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + node scripts/release/phase1.mjs ordinary-fetch \ + "$RUNNER_TEMP/ordinary-compare-tool/x86_64-unknown-linux-gnu/release/trusted-server-release" \ + "$RUNNER_TEMP/ordinary-inputs" "$RUNNER_TEMP/ordinary-archives" + - name: Compare every final byte and seal only a real matched companion + id: comparison + run: | + set -euo pipefail + if ! env -i PATH="$PATH" LC_ALL=C TZ=UTC \ + "$RUNNER_TEMP/ordinary-compare-tool/x86_64-unknown-linux-gnu/release/trusted-server-release" \ + ordinary-compare --input "$RUNNER_TEMP/ordinary-inputs" \ + --archives "$RUNNER_TEMP/ordinary-archives" --source "$PWD" \ + --output "$RUNNER_TEMP/ordinary-comparison" \ + 2> "$RUNNER_TEMP/ordinary-comparison-failure.txt"; then + head -c 16384 "$RUNNER_TEMP/ordinary-comparison-failure.txt" + exit 1 + fi + echo 'SUCCESS: actual canonical and reproduction Fastly bytes match; ordinary unqualified companion sealed' + - name: Upload the real Fastly rebuild check + if: steps.comparison.outcome == 'success' + uses: actions/upload-artifact@v6 + with: + name: fastly-rebuild-check + path: ${{ runner.temp }}/ordinary-comparison/trusted-server-fastly.wasm.rebuild-check.json + if-no-files-found: error + retention-days: 7 + - name: Retain bounded nonpublishable comparison diagnostics + if: failure() + run: | + set -euo pipefail + diagnostic="$RUNNER_TEMP/ordinary-diagnostics/ordinary-comparison-diagnostic.json" + if ! test -f "$diagnostic"; then + if ! node scripts/release/phase1.mjs ordinary-diagnostic "$RUNNER_TEMP/ordinary-diagnostics"; then + echo 'outcome=failed: ordinary diagnostic writer failed; job remains failed' >&2 + exit 1 + fi + fi + if test -f "$RUNNER_TEMP/ordinary-comparison-failure.txt"; then + head -c 16384 "$RUNNER_TEMP/ordinary-comparison-failure.txt" > "$RUNNER_TEMP/ordinary-comparison-diagnostic.txt" + else + echo 'Ordinary inputs unavailable; no publishable equality result' > "$RUNNER_TEMP/ordinary-comparison-diagnostic.txt" + fi + - name: Upload only the failed comparison diagnostic + if: failure() + uses: actions/upload-artifact@v6 + with: + name: fastly-comparison-failure + path: | + ${{ runner.temp }}/ordinary-comparison-diagnostic.txt + ${{ runner.temp }}/ordinary-diagnostics/ordinary-comparison-diagnostic.json + if-no-files-found: error + retention-days: 7 + sign: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.RELEASE_PUBLICATION_ENABLED == 'true' - needs: build + needs: [checks, build, fastly-comparison] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -147,32 +423,77 @@ jobs: ts-x86_64-unknown-linux-gnu, ] steps: - # Only fixed inline checks execute here; never checkout or execute build inputs. + # Only fixed inline checks execute here, on the pinned hosted Node; never + # checkout or execute build inputs. + # The checks job reads the pinned Node version, since this job has no checkout. + - uses: actions/setup-node@v5 + with: + node-version: ${{ needs.checks.outputs.node }} - uses: actions/download-artifact@v7 with: name: release-${{ matrix.asset }} path: release-assets + - uses: actions/download-artifact@v7 + if: matrix.asset == 'trusted-server-fastly.wasm' + with: + name: fastly-rebuild-check + path: comparison - name: Validate signing inputs and retain their digests + id: inputs env: ASSET: ${{ matrix.asset }} + SOURCE_COMMIT: ${{ github.sha }} + NODE_VERSION: ${{ needs.checks.outputs.node }} run: | set -euo pipefail - cd release-assets - test "$(find . -mindepth 1 -maxdepth 1 | wc -l)" -eq 2 - for file in "$ASSET" "$ASSET.spdx.json"; do test -f "$file" && test ! -L "$file" && test -s "$file" || exit 1; done - sha256sum "$ASSET" "$ASSET.spdx.json" > ../inputs.sha256 + # Validate only on the pinned Node that setup-node installed. + test "$(node --version)" = "v$NODE_VERSION" + node <<'NODE' + // BEGIN SIGNING INPUTS + const fs = require('node:fs'); + const { createHash } = require('node:crypto'); + const { ASSET: asset, SOURCE_COMMIT: source, GITHUB_RUN_ID: run, GITHUB_RUN_ATTEMPT: attempt } = process.env; + const names = ['trusted-server-fastly.wasm', 'ts-aarch64-apple-darwin', 'ts-x86_64-apple-darwin', 'ts-x86_64-unknown-linux-gnu']; + if (!names.includes(asset) || !/^[a-f0-9]{40}$/.test(source) || !/^[1-9][0-9]*$/.test(run) || !/^[1-9][0-9]*$/.test(attempt)) throw Error('Invalid signing identity'); + const hash = bytes => createHash('sha256').update(bytes).digest('hex'); + const regular = (path, limit) => { const stat = fs.lstatSync(path); if (!stat.isFile() || !stat.size || stat.size > limit) throw Error('Invalid signing input'); return fs.readFileSync(path); }; + const exact = (dir, files) => { if (JSON.stringify(fs.readdirSync(dir).sort()) !== JSON.stringify([...files].sort())) throw Error('Unexpected signing inputs'); }; + const inputs = [asset, `${asset}.spdx.json`]; + const packageName = 'trusted-server-fastly.tar.gz'; + if (asset === 'trusted-server-fastly.wasm') inputs.push(packageName); + exact('release-assets', inputs); + const binary = regular(`release-assets/${asset}`, 512 * 1024 * 1024), spdx = regular(`release-assets/${asset}.spdx.json`, 32 * 1024 * 1024); + if (asset === 'trusted-server-fastly.wasm') { + const companion = `${asset}.rebuild-check.json`; + exact('comparison', [companion]); + const bytes = regular(`comparison/${companion}`, 32 * 1024 * 1024), receipt = JSON.parse(bytes.toString('utf8')); + const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value; + if (!bytes.equals(Buffer.from(JSON.stringify(canonical(receipt)) + '\n'))) throw Error('Noncanonical or ambiguous rebuild check'); + if (receipt.version !== 1 || receipt.profile !== 'ordinary-release-fastly-byte-equality-v1' || receipt.release_qualification !== false || receipt.source_commit !== source || receipt.asset !== asset || receipt.target !== 'wasm32-wasip1' || receipt.variant !== 'default' || receipt.outcome !== 'matched' || receipt.canonical_sha256 !== hash(binary) || receipt.reproduction_sha256 !== hash(binary)) throw Error('Rebuild check identity or equality mismatch'); + for (const build of [receipt.canonical, receipt.reproduction]) if (!build || build.run_id !== Number(run) || !Number.isSafeInteger(build.run_attempt) || build.run_attempt < 1 || build.run_attempt > Number(attempt) || !Number.isSafeInteger(build.job_id) || build.job_id <= 0 || build.size !== binary.length || build.sha256 !== hash(binary)) throw Error('Rebuild check run or binary mismatch'); + if (receipt.canonical.job_id === receipt.reproduction.job_id || receipt.canonical.spdx_sha256 !== hash(spdx)) throw Error('Rebuild check job or SPDX mismatch'); + const packageBytes = regular(`release-assets/${packageName}`, 768 * 1024 * 1024), packaged = receipt.package; + if (!packaged || packaged.asset !== packageName || packaged.size !== packageBytes.length || packaged.canonical_sha256 !== hash(packageBytes) || packaged.reproduction_sha256 !== hash(packageBytes) || packaged.manifest_sha256 !== hash(Buffer.from('manifest_version = 3\nname = "trusted-server-fastly"\n'))) throw Error('Rebuild check package identity or equality mismatch'); + for (const build of [receipt.canonical, receipt.reproduction]) if (build.package?.size !== packageBytes.length || build.package?.sha256 !== hash(packageBytes)) throw Error('Rebuild check observed package mismatch'); + fs.writeFileSync(`release-assets/${companion}`, bytes, {flag:'wx'}); + inputs.push(companion); + } + exact('release-assets', inputs); + fs.writeFileSync('inputs.sha256', inputs.map(file => `${hash(regular(`release-assets/${file}`, 768 * 1024 * 1024))} ${file}\n`).join(''), {flag:'wx'}); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `subjects< `release-assets/${file}`).join('\n')}\nPROVENANCE_SUBJECTS\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `sbom_subjects<> SHA256SUMS) - uses: actions/upload-artifact@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5552a2206e..f5c38bc6f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ jobs: uses: ./.github/workflows/release-build.yml permissions: contents: read + actions: read id-token: write attestations: write release: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b0a26c648..2e39b2d645 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -196,6 +196,23 @@ jobs: - name: Run host-target CLI tests run: ./scripts/test-cli.sh + - name: Run release producer primitive and process tests + run: | + set -euo pipefail + cargo test --package trusted-server-release --target x86_64-unknown-linux-gnu --locked --test commands -- --list > "$RUNNER_TEMP/ordinary-command-tests.txt" + grep -Fx 'ordinary_compare_mismatch_writes_no_companion: test' "$RUNNER_TEMP/ordinary-command-tests.txt" + cargo test --package trusted-server-release --target x86_64-unknown-linux-gnu --locked + cargo test --package trusted-server-release --target x86_64-unknown-linux-gnu --locked --test commands ordinary_compare_mismatch_writes_no_companion -- --exact > "$RUNNER_TEMP/ordinary-negative-result.txt" + cat "$RUNNER_TEMP/ordinary-negative-result.txt" + grep -E '^test result: ok\. 1 passed; 0 failed; 0 ignored;' "$RUNNER_TEMP/ordinary-negative-result.txt" + + - name: Run checked and ordinary JS release build fixtures + shell: bash + run: | + cargo test --package trusted-server-js --target x86_64-unknown-linux-gnu --locked --test release_prebuilt -- --list > "$RUNNER_TEMP/js-release-tests.txt" + grep -Fx 'ordinary_release_without_prebuilt_builds_private_bundles: test' "$RUNNER_TEMP/js-release-tests.txt" + cargo test --package trusted-server-js --target x86_64-unknown-linux-gnu --locked --test release_prebuilt + # The codegen crate is not a default member and no adapter alias builds it, # so its tests only run if invoked explicitly on the host target. - name: Run host-target OpenRTB codegen tests @@ -351,6 +368,11 @@ jobs: run: | cargo clippy --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --all-targets -- -D warnings + - name: Run producer process supervision on macOS + if: runner.os == 'macOS' + run: | + cargo test --package trusted-server-release --target "$(rustc -vV | sed -n 's/host: //p')" --locked --bin trusted-server-release process:: + - name: Set up Chrome for browser fixture tests if: runner.os == 'macOS' id: setup-chrome @@ -371,6 +393,11 @@ jobs: TRUSTED_SERVER__GIT_VERSION: abcdef0123456789abcdef0123456789abcdef01 run: | cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --test release_build_facts + # The release cfg also selects resource discovery without the + # builder's checkout path; only a release-mode lib build reaches it. + cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --lib prebid_bundle::tests::actual_resource_discovery_uses_owning_release_cfg -- --exact > "$RUNNER_TEMP/release-cfg-result.txt" + cat "$RUNNER_TEMP/release-cfg-result.txt" + grep -E '^test result: ok\. 1 passed; 0 failed;' "$RUNNER_TEMP/release-cfg-result.txt" # A valid commit also passes when the release gate is bypassed, so check # that core's build script refuses a branch name in release mode. diff --git a/AGENTS.md b/AGENTS.md index bf31f54858..3ad4da3fd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -377,7 +377,9 @@ Every PR must pass: 13. CI Node pin guard: `node --test scripts/check-ci-node-pin.test.mjs && node scripts/check-ci-node-pin.mjs` with the `.tool-versions` Node (every CI job that compiles Rust sets up the `.tool-versions` Node before its first cargo step) 14. Fastly absolute-deadline network fixtures under Viceroy (the harness builds the adapter test binary itself): `node crates/trusted-server-adapter-fastly/tests/deadline-origin.mjs "$(command -v viceroy)"` 15. Worker auction-deadline runtime fixtures (`worker-auction-deadlines.yml`): build and lint the runtime fixture, then run it under the local runtime. For Cloudflare, `cargo build --locked -p trusted-server-adapter-cloudflare --example deadline-runtime --target wasm32-unknown-unknown --features cloudflare` and `node scripts/test-worker-auction-deadlines.mjs cloudflare --runtime "$(command -v wrangler)"` (requires wrangler and the `Cargo.lock` wasm-bindgen CLI). For Spin, `cargo build --locked -p trusted-server-adapter-spin --example deadline-runtime --target wasm32-wasip1 --features spin` and `node scripts/test-worker-auction-deadlines.mjs spin --runtime "$(command -v spin)"`. CI lints each fixture with the same `cargo clippy` arguments plus `-- -D warnings` -16. Release producer checks: `node --test scripts/release/phase1.test.mjs && shellcheck scripts/release/phase1-build.sh` +16. Release producer helpers: `node --test scripts/release/phase1.test.mjs && shellcheck scripts/release/phase1-build.sh scripts/release/validate-fastly-package.sh scripts/release/bootstrap.sh scripts/release/build.sh scripts/release/build-js.sh` +17. Native release producer tests and lint: `cargo test --package trusted-server-release --target --locked` and `cargo clippy --package trusted-server-release --target --all-targets --all-features --locked -- -D warnings` (CI uses `x86_64-unknown-linux-gnu`). +18. Native checked/ordinary JS release fixtures: `cargo test --package trusted-server-js --target --locked --test release_prebuilt` and `cargo clippy --package trusted-server-js --target --all-targets --all-features --locked -- -D warnings` (CI uses `x86_64-unknown-linux-gnu`). CI also runs these HTML EOF checks. Run them locally only when changing EOF finalization, publisher or HTML-processor code: diff --git a/CHANGELOG.md b/CHANGELOG.md index dc8a24e872..88c3f409ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Publish a reproducible, provenance- and SBOM-attested `trusted-server-fastly.tar.gz` alongside the Fastly wasm. Both ordinary builds use the same fixed service-neutral manifest and normalized archive metadata; the gate compares both complete files before signing. Operators can verify and deploy the downloaded package unchanged. + +- Add an ordinary Fastly release byte-equality gate and a real provenance-attested `trusted-server-fastly.wasm.rebuild-check.json`. Release CI now builds the Fastly wasm a second time on every push to `main` and every `v*` tag and requires both complete files to match byte for byte; a mismatch prevents signing and publication. The ordinary profile remains unqualified, native reproduction is not a release gate, and the four binary names and signer identity stay unchanged. - Add Phase 1 attested release binaries: the Fastly wasm and the `ts` CLI for Linux x86-64 and macOS (ARM64 and x86-64), with SPDX 2.3 inventories, provenance/SBOM bundles, immutable verification and a GNU glibc 2.34 startup check. Reproducibility, other adapters and the custom verifier remain later work; production publication requires maintainer settings. ### Changed - **Breaking:** Known `[integrations.*]` tables, `[tester_cookie]`, and `[tinybird]` now reject unknown fields, including in disabled integration blocks. Integration parse errors name the offending key. `ts config validate` and `ts config push` also reject unknown integration IDs and list the supported IDs; runtime startup warns and ignores unknown IDs for compatibility with stored configs. Before deploying the new binary, use the new CLI to run `ts config validate` on the current config, correct the reported fields and IDs, then push the cleaned config while the old binary is still running. On Fastly, wait for the config-store propagation window to elapse before activating the new binary; a successful push does not mean every point of presence (POP) has the new config. Stored unknown fields can otherwise prevent startup. Intentionally open payload maps remain supported. Lockr uses `app_id`; remove unsupported `organization_id` and `project_id` fields. +- **Breaking:** The tsjs bundle build (`crates/trusted-server-js/lib/build-all.mjs`, run by Cargo builds of `trusted-server-js` and by `npm run build`) now requires a Node.js with `import.meta.main` (22.18 or later, or 24.2 or later) and fails on older versions; use the `.tool-versions` Node. +- **Breaking:** Cargo builds of `trusted-server-js` now fail whenever `TSJS_SKIP_BUILD` is set to any value, including empty or `0`; previously only `TSJS_SKIP_BUILD=1` was rejected. Remove it from shell profiles and `.env` files, and use `TSJS_PREBUILT_DIR` to embed prebuilt bundles instead. - The attested Fastly wasm reports `x-ts-version` as the full 40-character source commit; deploy-pipeline builds keep the tag, branch or short-commit value described below. - **Breaking:** `ts --version` now prints `ts package= source= target= variant=default` instead of `ts `. `source` is the full commit in attested release binaries, and otherwise the development ref, short commit or `unknown`. Update scripts that parse the old output. - **Breaking:** Creative rewrite proxy, click, CSS, `srcset`, sign-response, rebuild-response, and injected creative TSJS URLs are now absolute on `publisher.public_origin`. Review the [default authority, cookie scope, and coordinated upgrade/rollback warning for `public_origin`](docs/guide/configuration.md#publisher-configuration) before deployment. Existing root-relative signed proxy and click inputs remain accepted. TSJS bundle selection and hash calculation are unchanged. diff --git a/Cargo.lock b/Cargo.lock index 70316712ac..5233178fde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6078,6 +6078,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "temp-env" version = "0.3.6" @@ -6779,8 +6789,11 @@ version = "0.1.0" dependencies = [ "build-print", "hex", + "libc", + "serde", "serde_json", "sha2 0.11.1", + "tempfile", "which", ] @@ -6800,6 +6813,26 @@ dependencies = [ "prost-build", ] +[[package]] +name = "trusted-server-release" +version = "0.1.0" +dependencies = [ + "clap", + "derive_more", + "error-stack", + "flate2", + "hex", + "libc", + "serde", + "serde_json", + "sha2 0.11.1", + "tar", + "tempfile", + "time", + "toml", + "wasmparser", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 139cf4f610..93897c6bc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ # Manual protobuf codegen tool — a member so it shares workspace settings, # but no default member or alias builds it. Run it explicitly with `-p`. "crates/trusted-server-openrtb-codegen", + "crates/trusted-server-release", ] # Viceroy (cargo test-fastly runner) calls `cargo run --bin trusted-server-adapter-fastly` diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 9edce69fa3..89347d66bd 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -568,8 +568,8 @@ impl TrustedServerApp { /// Build the full application router from explicit settings. /// /// Testing seam: cross-adapter parity tests use this to drive the router - /// with known-good settings instead of the baked `get_settings()` result, - /// whose embedded placeholder secrets fail validation by design. + /// with known-good settings without external runtime configuration or + /// secret-store bindings. /// /// # Errors /// diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 4dc3cbee47..128a8e0b95 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -221,9 +221,8 @@ mod tests { } fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { - // Build from explicit test settings: the settings baked into the - // binary contain placeholder secrets that `get_settings()` rejects - // by design. + // Build explicit, deterministic test settings without external runtime + // configuration or secret-store bindings. let mut s = Settings::from_toml( r#" [[handlers]] diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 6fa45aa6fa..64a212b35c 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -14,9 +14,9 @@ use trusted_server_core::settings::Settings; /// Build the full application router from explicit test settings. /// -/// The settings baked into the binary contain placeholder secrets that -/// `get_settings()` rejects by design, which would turn every route into a -/// startup error page (and its route table into the fallback-only set). +/// Explicit settings exercise the full route table without external runtime +/// configuration or secret-store bindings. Missing runtime settings would +/// otherwise select the startup error router. /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. diff --git a/crates/trusted-server-cli/build.rs b/crates/trusted-server-cli/build.rs index e3c730445d..280bd38b10 100644 --- a/crates/trusted-server-cli/build.rs +++ b/crates/trusted-server-cli/build.rs @@ -8,6 +8,13 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD"); + println!("cargo:rustc-check-cfg=cfg(trusted_server_release_build)"); + // Core's build script rejects an invalid release control for every product + // build; this only selects release-only resource discovery. + if std::env::var_os("TRUSTED_SERVER__RELEASE_BUILD").is_some() { + println!("cargo:rustc-cfg=trusted_server_release_build"); + } let target = std::env::var("TARGET").expect("should set the owning Cargo target"); println!("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET={target}"); } diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs index 22b21fbc57..32433e5262 100644 --- a/crates/trusted-server-cli/src/prebid_bundle.rs +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -627,6 +627,18 @@ fn ensure_file_exists(path: &Path, description: &str) -> CliResult<()> { } fn find_js_lib_dir(start: &Path) -> CliResult { + #[cfg(not(trusted_server_release_build))] + let fallback = Some( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("crates/trusted-server-js/lib"), + ); + #[cfg(trusted_server_release_build)] + let fallback: Option = None; + find_js_lib_dir_with_fallback(start, fallback.as_deref()) +} + +fn find_js_lib_dir_with_fallback(start: &Path, fallback: Option<&Path>) -> CliResult { for ancestor in start.ancestors() { let candidate = ancestor.join("crates/trusted-server-js/lib"); if is_js_lib_dir(&candidate) { @@ -634,11 +646,9 @@ fn find_js_lib_dir(start: &Path) -> CliResult { } } - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let candidate = manifest_dir - .join("../..") - .join("crates/trusted-server-js/lib"); - if is_js_lib_dir(&candidate) { + if let Some(candidate) = fallback + && is_js_lib_dir(candidate) + { return candidate.canonicalize().map_err(|error| { report_error(format!( "failed to resolve JS library directory {}: {error}", @@ -815,6 +825,71 @@ mod tests { #[cfg(unix)] use std::os::unix::fs::PermissionsExt as _; + #[test] + fn resource_discovery_without_fallback_requires_runtime_checkout() { + let directory = tempfile::tempdir().expect("should create outside checkout fixture"); + let error = find_js_lib_dir_with_fallback(directory.path(), None) + .expect_err("should require runtime checkout when fallback is absent"); + assert!( + error + .to_string() + .contains("run `ts prebid client` from the Trusted Server repository"), + "should explain the missing source prerequisites" + ); + let resources = directory.path().join("crates/trusted-server-js/lib"); + fs::create_dir_all(&resources).expect("should create runtime resources"); + fs::write(resources.join("package.json"), "{}").expect("should create package fixture"); + fs::write(resources.join("build-prebid-external.mjs"), "") + .expect("should create builder fixture"); + let nested = directory.path().join("nested"); + fs::create_dir(&nested).expect("should create nested current directory"); + assert_eq!( + find_js_lib_dir_with_fallback(&nested, None) + .expect("should discover actual runtime checkout"), + resources, + "should discover runtime ancestor resources" + ); + } + + #[test] + fn resource_discovery_uses_explicit_development_fallback() { + let directory = tempfile::tempdir().expect("should create fixture"); + let resources = directory.path().join("development-lib"); + fs::create_dir(&resources).expect("should create development fallback"); + fs::write(resources.join("package.json"), "{}").expect("should create package fixture"); + fs::write(resources.join("build-prebid-external.mjs"), "") + .expect("should create builder fixture"); + assert_eq!( + find_js_lib_dir_with_fallback(directory.path(), Some(&resources)) + .expect("should use explicit development fallback"), + resources + .canonicalize() + .expect("should canonicalize fallback"), + "should preserve development fallback behavior" + ); + } + + #[test] + fn actual_resource_discovery_uses_owning_release_cfg() { + let directory = tempfile::tempdir().expect("should create outside checkout fixture"); + let result = find_js_lib_dir(directory.path()); + if cfg!(trusted_server_release_build) { + let error = + result.expect_err("should exclude the compiled developer checkout in release mode"); + assert!( + error + .to_string() + .contains("run `ts prebid client` from the Trusted Server repository"), + "should identify release source prerequisites" + ); + } else { + assert!( + result.is_ok(), + "should retain the compiled checkout fallback in development" + ); + } + } + fn managed_config(managed: &str, user_id: &str) -> String { format!( r#" diff --git a/crates/trusted-server-cli/tests/release_build_facts.rs b/crates/trusted-server-cli/tests/release_build_facts.rs index 9c165133ba..455199d61e 100644 --- a/crates/trusted-server-cli/tests/release_build_facts.rs +++ b/crates/trusted-server-cli/tests/release_build_facts.rs @@ -72,11 +72,22 @@ fn cli_build_script_emits_only_its_target() { ) .expect("should emit UTF-8 directives"), ); - for directives in &outputs { + for (index, directives) in outputs.iter().enumerate() { + // Release-only resource discovery is selected for the two release runs. + let release_cfg = if index < 2 { + "cargo:rustc-cfg=trusted_server_release_build\n" + } else { + "" + }; assert_eq!( directives, - "cargo:rerun-if-changed=build.rs\n\ - cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET=example-target\n", + &format!( + "cargo:rerun-if-changed=build.rs\n\ + cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD\n\ + cargo:rustc-check-cfg=cfg(trusted_server_release_build)\n\ + {release_cfg}\ + cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET=example-target\n" + ), "should emit only the target owned by Cargo TARGET" ); } diff --git a/crates/trusted-server-integration-tests/browser/helpers/infra.ts b/crates/trusted-server-integration-tests/browser/helpers/infra.ts index cd83913da5..55792e3f10 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/infra.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/infra.ts @@ -38,8 +38,8 @@ export async function findAvailablePort(): Promise { * Start a Docker container for the given framework. * * Uses `docker run -d` (detached) with `--rm` for auto-cleanup on stop. - * The container is mapped to the fixed origin port so the WASM binary - * (which has this port baked in) can proxy to it. + * The container is mapped to the fixed origin port expected by the runtime + * application configuration so the server can proxy to it. * * @returns The container ID. */ diff --git a/crates/trusted-server-integration-tests/tests/common/runtime.rs b/crates/trusted-server-integration-tests/tests/common/runtime.rs index 048c76d44b..756e021315 100644 --- a/crates/trusted-server-integration-tests/tests/common/runtime.rs +++ b/crates/trusted-server-integration-tests/tests/common/runtime.rs @@ -81,10 +81,9 @@ pub trait RuntimeProcessHandle: Send + Sync {} /// Trait defining how to run the trusted-server on different platforms. /// -/// The application configuration (origin URL, integrations, etc.) is baked -/// into the WASM binary at build time via `build.rs`. The runtime environment -/// only needs the WASM binary path and its own platform-specific config -/// (e.g. Viceroy's `fastly.toml` for KV stores and secret stores). +/// Runtime fixtures supply application configuration (origin URL, integrations, +/// etc.) separately from the WASM binary, alongside platform-specific resources +/// (e.g. Viceroy's `fastly.toml` config, KV and secret stores). pub trait RuntimeEnvironment: Send + Sync { /// Platform identifier (e.g., "fastly", "cloudflare") fn id(&self) -> &'static str; @@ -122,8 +121,8 @@ pub fn wasm_binary_path() -> PathBuf { /// Get the fixed origin port used for Docker container port mapping. /// -/// This must match the port baked into the WASM binary via -/// `TRUSTED_SERVER__PUBLISHER__ORIGIN_URL` at build time. +/// This must match the origin port in the generated runtime application +/// configuration and the container host-port mapping. pub fn origin_port() -> u16 { match std::env::var("INTEGRATION_ORIGIN_PORT") { Ok(value) => value diff --git a/crates/trusted-server-integration-tests/tests/frameworks/mod.rs b/crates/trusted-server-integration-tests/tests/frameworks/mod.rs index e77fb9b91d..14cff07b1c 100644 --- a/crates/trusted-server-integration-tests/tests/frameworks/mod.rs +++ b/crates/trusted-server-integration-tests/tests/frameworks/mod.rs @@ -24,8 +24,8 @@ pub trait FrontendFramework: Send + Sync { /// Build a Docker container request mapped to the given origin port. /// - /// The `origin_port` is the fixed host port that the WASM binary - /// expects the origin to be running on (baked in at build time). + /// The `origin_port` is the fixed host port where the runtime test + /// configuration expects the origin to be running. /// /// # Errors /// diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 60d4f44955..b70a655c6a 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -31,9 +31,9 @@ use trusted_server_core::test_support::nextjs_auction; /// Shared test settings for all adapters. /// -/// The settings baked into the binaries contain placeholder secrets that -/// `get_settings()` rejects by design, so all routers are built through -/// their `routes_with_settings` testing seams from this known-good config. +/// All routers use their `routes_with_settings` testing seams with known-good +/// settings so these tests do not depend on external runtime configuration +/// or secret-store bindings. /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index 5a382a53a2..83389b8845 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -20,8 +20,18 @@ path = "src/lib.rs" build-print = { workspace = true } hex = { workspace = true } sha2 = { workspace = true } +which = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } +libc = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +build-print = { workspace = true } which = { workspace = true } +serde = { workspace = true } +libc = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } [dependencies] hex = { workspace = true } diff --git a/crates/trusted-server-js/README.md b/crates/trusted-server-js/README.md index 254ab6f55c..6131338410 100644 --- a/crates/trusted-server-js/README.md +++ b/crates/trusted-server-js/README.md @@ -12,6 +12,14 @@ bundle is missing or empty; the `dist` directory is written only by a manual explicitly through `TSJS_PREBUILT_DIR`. The external Prebid.js artifact is built separately and is not the deferred Trusted Server Prebid shim. +Ordinary attested release builds still run npm and embed fresh private output; +their release script refuses prebuilt overrides. The separate checked-prebuilt +path requires both `TSJS_PREBUILT_DIR` and +`TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` when a release build explicitly selects +prebuilt input. It validates the inventory's source commit and exact bundle set, +then checks the retained copies. This input check does not qualify the closed +controlled recipes or prove complete composition. + Run the JavaScript checks from `crates/trusted-server-js/lib`: ```bash diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 0fc7b01abd..893e4e9517 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -1,13 +1,22 @@ #![allow( clippy::expect_used, clippy::panic, - reason = "build script failures should stop Cargo with a clear diagnostic" + clippy::print_stdout, + reason = "Cargo directives require stdout and build script failures must stop the build" )] #[path = "build/bundle_set.rs"] mod bundle_set; +#[allow(dead_code, reason = "only the shared release guard is needed here")] +#[path = "../trusted-server-core/build_support/git_version.rs"] +mod git_version; +#[path = "build/inventory.rs"] +mod inventory; +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[path = "build/prebuilt.rs"] +mod prebuilt; #[path = "build/trace_sources.rs"] -mod trace_sources; +pub(crate) mod trace_sources; use std::env; use std::fmt::Write as _; @@ -18,20 +27,29 @@ use std::process::{Command, ExitStatus}; use build_print::info; use sha2::{Digest as _, Sha256}; -use crate::bundle_set::{bundle_file_name, check_bundle_set, expected_module_ids, scan_bundle_dir}; -use crate::trace_sources::trace_source_digest; +use self::bundle_set::{bundle_file_name, check_bundle_set, expected_module_ids, scan_bundle_dir}; +use self::trace_sources::trace_source_digest; const PREBUILT_DIR_VAR: &str = "TSJS_PREBUILT_DIR"; +const INVENTORY_VAR: &str = "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY"; +const RELEASE_VAR: &str = "TRUSTED_SERVER__RELEASE_BUILD"; +const SOURCE_VAR: &str = "TRUSTED_SERVER__GIT_VERSION"; const SKIP_BUILD_VAR: &str = "TSJS_SKIP_BUILD"; const TEST_VAR: &str = "TSJS_TEST"; fn main() { + run_with_hook(|_, _| {}); +} + +/// Run the actual build script with a crate-private post-copy race fixture. +pub(crate) fn run_with_hook(hook: impl FnMut(&Path, &Path)) { // Cargo scans directories recursively, so these cover every TS source. // Every path must exist: a missing entry makes Cargo rerun this script on // every build, the opposite of the intent. for path in [ "lib/src", "lib/build-all.mjs", + "lib/build-module-options.mjs", "lib/trace-asset-sources.mjs", "lib/build-trace.mjs", "lib/trace-build-inputs.json", @@ -39,6 +57,8 @@ fn main() { "lib/trace-assets-manifest.json", "lib/trace-assets", "lib/build-prebid-external.mjs", + "lib/release-composition.mjs", + "lib/vitest.config.ts", "lib/package.json", "lib/package-lock.json", "lib/tsconfig.json", @@ -46,10 +66,53 @@ fn main() { ] { println!("cargo:rerun-if-changed={path}"); } - for var in [PREBUILT_DIR_VAR, SKIP_BUILD_VAR, TEST_VAR] { + for var in [ + PREBUILT_DIR_VAR, + INVENTORY_VAR, + SKIP_BUILD_VAR, + TEST_VAR, + RELEASE_VAR, + SOURCE_VAR, + ] { println!("cargo:rerun-if-env-changed={var}"); } + assert!( + env::var_os(SKIP_BUILD_VAR).is_none(), + "tsjs: {SKIP_BUILD_VAR} is no longer supported whenever present; run `npm run build` \ + in crates/trusted-server-js/lib and set {PREBUILT_DIR_VAR} to embed prebuilt bundles \ + instead" + ); + let source = env::var_os(SOURCE_VAR); + let release = + git_version::release_source(env::var_os(RELEASE_VAR).as_deref(), source.as_deref()) + .unwrap_or_else(|error| panic!("tsjs: {error}")); + let prebuilt_dir = env::var_os(PREBUILT_DIR_VAR).map(PathBuf::from); + let inventory_path = env::var_os(INVENTORY_VAR).map(PathBuf::from); + // Ordinary releases build fresh private bundles. Selecting prebuilt release + // inputs instead requires the complete checked pair, never a loose folder. + if release.is_some() && (prebuilt_dir.is_some() || inventory_path.is_some()) { + assert!( + prebuilt_dir.is_some(), + "tsjs: release builds require {PREBUILT_DIR_VAR}" + ); + assert!( + inventory_path.is_some(), + "tsjs: release builds require {INVENTORY_VAR}" + ); + } + assert!( + inventory_path.is_none() || prebuilt_dir.is_some(), + "tsjs: {INVENTORY_VAR} requires {PREBUILT_DIR_VAR}" + ); + if let Some(path) = &inventory_path { + assert!( + path.is_absolute(), + "tsjs: {INVENTORY_VAR} must be an absolute path" + ); + println!("cargo:rerun-if-changed={}", path.display()); + } + let crate_dir = PathBuf::from( env::var("CARGO_MANIFEST_DIR").expect("should set CARGO_MANIFEST_DIR for build script"), ); @@ -57,11 +120,12 @@ fn main() { let ts_dir = crate_dir.join("lib"); // Private to this build script run: concurrent builds never share it. let bundle_dir = out_dir.join("tsjs-dist"); + let trace_dir = out_dir.join("tsjs-trace-dist"); let expected = expected_module_ids(&ts_dir.join("src")) .unwrap_or_else(|err| panic!("tsjs: failed to discover modules: {err}")); - if let Some(prebuilt_dir) = env::var_os(PREBUILT_DIR_VAR).map(PathBuf::from) { + if let Some(prebuilt_dir) = prebuilt_dir { assert!( prebuilt_dir.is_absolute(), "tsjs: {PREBUILT_DIR_VAR} must be an absolute path because Cargo runs build \ @@ -73,10 +137,44 @@ fn main() { "tsjs: Using prebuilt bundles from {}", prebuilt_dir.display() ); - validate_bundle_dir(&expected, &prebuilt_dir); - copy_prebuilt_bundles(&expected, &prebuilt_dir, &bundle_dir); + if let Some(path) = inventory_path { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + prebuilt::inventory_outside(&prebuilt_dir, &path).unwrap_or_else(|error| { + panic!("tsjs: invalid inventory path {}: {error}", path.display()) + }); + let inventory = inventory::read_inventory(&path, &expected, release.as_deref()) + .unwrap_or_else(|error| { + panic!("tsjs: invalid inventory {}: {error}", path.display()) + }); + prebuilt::copy_checked( + &expected, + &prebuilt_dir, + &bundle_dir, + &inventory.inventory, + hook, + ) + .unwrap_or_else(|error| { + panic!( + "tsjs: invalid prebuilt inventory inputs in {}: {error}", + prebuilt_dir.display() + ) + }); + // Cargo JSON build-script messages expose the prepared input + // identity to the later release evidence collector. + println!( + "cargo:rustc-env=TRUSTED_SERVER__TSJS_INVENTORY_SHA256={}", + inventory.raw_sha256 + ); + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + panic!("tsjs: checked prebuilt copying requires a supported Linux or macOS build host"); + } else { + copy_prebuilt(&expected, &prebuilt_dir, &bundle_dir); + } } else { build_bundles(&ts_dir, &bundle_dir); + move_rebuilt_trace(&bundle_dir, &trace_dir); } validate_bundle_dir(&expected, &bundle_dir); @@ -89,12 +187,28 @@ fn main() { write_module_table(&expected, &bundle_dir, &out_dir); embed_trace_assets( &ts_dir, - &bundle_dir, + &trace_dir, &out_dir, env::var_os(PREBUILT_DIR_VAR).is_none(), ); } +fn copy_prebuilt(expected: &[String], source: &Path, destination: &Path) { + let found = + bundle_set::scan_legacy_bundle_dir(source).unwrap_or_else(|error| panic!("tsjs: {error}")); + check_bundle_set(expected, &found) + .unwrap_or_else(|error| panic!("tsjs: invalid prebuilt bundle set: {error}")); + if destination.exists() { + fs::remove_dir_all(destination).expect("should clear private prebuilt destination"); + } + fs::create_dir_all(destination).expect("should create private prebuilt destination"); + for id in expected { + let filename = bundle_file_name(id); + fs::copy(source.join(&filename), destination.join(&filename)) + .unwrap_or_else(|error| panic!("tsjs: failed to copy {filename}: {error}")); + } +} + fn build_bundles(ts_dir: &Path, bundle_dir: &Path) { let how_to_prebuild = format!( "To embed prebuilt bundles instead, run `npm run build` in {} and set \ @@ -103,11 +217,6 @@ fn build_bundles(ts_dir: &Path, bundle_dir: &Path) { ts_dir.with_file_name("dist").display() ); - assert!( - !env::var(SKIP_BUILD_VAR).is_ok_and(|value| value == "1"), - "tsjs: {SKIP_BUILD_VAR} is no longer supported because it embedded whatever \ - dist/ held. {how_to_prebuild}" - ); assert!( ts_dir.join("package.json").is_file(), "tsjs: {} not found. {how_to_prebuild}", @@ -220,6 +329,18 @@ fn ensure_dependencies_fresh(ts_dir: &Path) { assert!(hidden_modified >= lockfile_modified, "{stale_message}"); } +/// Move the trace viewer `npm run build` writes beside the bundles. +/// +/// The checked bundle directory must hold only the bundles it embeds. +fn move_rebuilt_trace(bundle_dir: &Path, destination: &Path) { + if destination.exists() { + fs::remove_dir_all(destination).expect("should clear private rebuilt trace destination"); + } + fs::create_dir_all(destination).expect("should create private rebuilt trace destination"); + fs::rename(bundle_dir.join("trace"), destination.join("trace")) + .unwrap_or_else(|error| panic!("tsjs: failed to move the rebuilt trace viewer: {error}")); +} + fn validate_bundle_dir(expected: &[String], dir: &Path) { let found = scan_bundle_dir(dir).unwrap_or_else(|err| panic!("tsjs: {err}")); if let Err(err) = check_bundle_set(expected, &found) { @@ -227,27 +348,6 @@ fn validate_bundle_dir(expected: &[String], dir: &Path) { } } -fn copy_prebuilt_bundles(expected: &[String], prebuilt_dir: &Path, bundle_dir: &Path) { - if bundle_dir.exists() { - fs::remove_dir_all(bundle_dir) - .unwrap_or_else(|err| panic!("tsjs: failed to clean {}: {err}", bundle_dir.display())); - } - fs::create_dir_all(bundle_dir) - .unwrap_or_else(|err| panic!("tsjs: failed to create {}: {err}", bundle_dir.display())); - for id in expected { - let file_name = bundle_file_name(id); - let source = prebuilt_dir.join(&file_name); - let target = bundle_dir.join(&file_name); - fs::copy(&source, &target).unwrap_or_else(|err| { - panic!( - "tsjs: failed to copy {} to {}: {err}", - source.display(), - target.display() - ) - }); - } -} - fn write_module_table(expected: &[String], bundle_dir: &Path, out_dir: &Path) { let mut codegen = String::new(); codegen.push_str("// Auto-generated by build.rs - DO NOT EDIT\n\n"); diff --git a/crates/trusted-server-js/build/bundle_set.rs b/crates/trusted-server-js/build/bundle_set.rs index ec9b52ab34..acb6edd805 100644 --- a/crates/trusted-server-js/build/bundle_set.rs +++ b/crates/trusted-server-js/build/bundle_set.rs @@ -14,6 +14,14 @@ pub(crate) fn bundle_file_name(id: &str) -> String { format!("tsjs-{id}.js") } +/// Whether the ID is an ASCII identifier safe in a file and generated Rust. +pub(crate) fn safe_module_id(id: &str) -> bool { + !id.is_empty() + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + /// Return the module IDs `build-all.mjs` builds from `lib_src`. /// /// Mirrors the discovery in `build-all.mjs`: `core` plus every @@ -51,6 +59,17 @@ pub(crate) fn expected_module_ids(lib_src: &Path) -> Result, String> let mut ids = Vec::with_capacity(integrations.len() + 1); ids.push(CORE_MODULE_ID.to_owned()); ids.extend(integrations); + if let Some(id) = ids.iter().find(|id| !safe_module_id(id)) { + return Err(format!("module ID {id} must be a safe ASCII identifier")); + } + if ids + .iter() + .filter(|id| id.as_str() == CORE_MODULE_ID) + .count() + != 1 + { + return Err("duplicate core module ID".to_owned()); + } Ok(ids) } @@ -60,6 +79,19 @@ pub(crate) fn expected_module_ids(lib_src: &Path) -> Result, String> /// /// Returns a message when the directory or a bundle's metadata cannot be read. pub(crate) fn scan_bundle_dir(dir: &Path) -> Result, String> { + scan(dir, true) +} + +/// Scan development prebuilt bundles, ignoring legacy nonbundle output. +/// +/// # Errors +/// +/// Returns a message when the directory or a bundle's metadata cannot be read. +pub(crate) fn scan_legacy_bundle_dir(dir: &Path) -> Result, String> { + scan(dir, false) +} + +fn scan(dir: &Path, checked: bool) -> Result, String> { let entries = fs::read_dir(dir) .map_err(|err| format!("failed to read bundle directory {}: {err}", dir.display()))?; let mut found = Vec::new(); @@ -71,12 +103,21 @@ pub(crate) fn scan_bundle_dir(dir: &Path) -> Result, String> .strip_prefix("tsjs-") .and_then(|stem| stem.strip_suffix(".js")) else { + if checked { + return Err(format!("unexpected file in bundle directory: {file_name}")); + } continue; }; - let len = entry - .metadata() - .map_err(|err| format!("failed to read metadata for {file_name}: {err}"))? - .len(); + let metadata = if checked { + fs::symlink_metadata(entry.path()) + } else { + fs::metadata(entry.path()) + } + .map_err(|err| format!("failed to read metadata for {file_name}: {err}"))?; + if !safe_module_id(id) || !metadata.is_file() { + return Err(format!("unsafe or nonregular bundle: {file_name}")); + } + let len = metadata.len(); found.push((id.to_owned(), len)); } Ok(found) @@ -106,6 +147,10 @@ pub(crate) fn check_bundle_set(expected: &[String], found: &[(String, u64)]) -> unexpected.sort_unstable(); let mut problems = Vec::new(); + let mut ids = std::collections::BTreeSet::new(); + if found.iter().any(|(id, _)| !ids.insert(id)) { + problems.push("duplicate bundle IDs".to_owned()); + } if !missing.is_empty() { problems.push(format!("missing bundles: {missing:?}")); } @@ -138,6 +183,30 @@ mod tests { .collect() } + #[test] + fn module_identifiers_accept_only_safe_ascii() { + for id in ["core", "example_1", "example-2", "UPPER"] { + assert!(safe_module_id(id), "should accept safe ID {id}"); + } + for id in ["", "../core", "has space", "quote\"", "é", "a/b", "a\\b"] { + assert!(!safe_module_id(id), "should reject unsafe ID {id:?}"); + } + } + + #[test] + fn rejects_duplicate_found_ids_including_core() { + for id in ["core", "gpt"] { + let mut entries = found(&[("core", 10), ("gpt", 20), ("prebid", 30)]); + entries.push((id.to_owned(), 10)); + let error = check_bundle_set(&expected(), &entries) + .expect_err("should reject duplicate found ID"); + assert!( + error.contains("duplicate bundle IDs"), + "should explain duplicate: {error}" + ); + } + } + fn found(entries: &[(&str, u64)]) -> Vec<(String, u64)> { entries .iter() diff --git a/crates/trusted-server-js/build/inventory.rs b/crates/trusted-server-js/build/inventory.rs new file mode 100644 index 0000000000..0b5b5ad587 --- /dev/null +++ b/crates/trusted-server-js/build/inventory.rs @@ -0,0 +1,337 @@ +//! Strict, bounded parsing of the prebuilt input inventory. +//! +//! Contribution, tool and material values are opaque here: composition joins +//! belong to the release producer, rather than the Rust input consumer. + +use std::collections::BTreeSet; +use std::fmt; +#[cfg(not(unix))] +use std::fs::File; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::{self, Read as _}; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::Path; + +use serde::Deserialize; +use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value}; +use sha2::{Digest as _, Sha256}; + +use super::bundle_set::{bundle_file_name, check_bundle_set, safe_module_id}; + +const MAX_INVENTORY: u64 = 1024 * 1024; +// Release producers must enforce the same per-module input budget. +pub(crate) const MAX_BUNDLE: u64 = 32 * 1024 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Inventory { + schema_version: u64, + source_commit: String, + pub(crate) modules: Vec, + tools: Vec, + materials: Vec, +} + +/// Parsed input and the digest of its exact bounded raw bytes. +pub(crate) struct CheckedInventory { + pub(crate) inventory: Inventory, + pub(crate) raw_sha256: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Module { + pub(crate) id: String, + pub(crate) filename: String, + pub(crate) size: u64, + pub(crate) sha256: String, + contributions: Vec, +} + +pub(crate) fn invalid(message: impl fmt::Display) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.to_string()) +} + +pub(crate) fn read_inventory( + path: &Path, + expected: &[String], + source: Option<&str>, +) -> io::Result { + let leaf_path = path.components().collect::(); + #[cfg(unix)] + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(&leaf_path) + .map_err(|error| invalid(format!("cannot open inventory {}: {error}", path.display())))?; + #[cfg(not(unix))] + let file = File::open(&leaf_path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(invalid("inventory must be a regular file")); + } + #[cfg(unix)] + if metadata.nlink() != 1 { + return Err(invalid("inventory must have exactly one hard link")); + } + let mut bytes = Vec::new(); + (&file).take(MAX_INVENTORY + 1).read_to_end(&mut bytes)?; + #[cfg(unix)] + { + let after = file.metadata()?; + if ( + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + metadata.nlink(), + ) != ( + after.len(), + after.mtime(), + after.mtime_nsec(), + after.ctime(), + after.ctime_nsec(), + after.nlink(), + ) { + return Err(invalid("inventory changed while reading")); + } + } + if bytes.len() as u64 > MAX_INVENTORY { + return Err(invalid("inventory exceeds 1 MiB")); + } + let text = std::str::from_utf8(&bytes).map_err(|_| invalid("inventory must be UTF-8"))?; + validate_numbers(text)?; + let mut parser = serde_json::Deserializer::from_str(text); + let value = StrictValue(0).deserialize(&mut parser).map_err(invalid)?; + parser.end().map_err(invalid)?; + let inventory: Inventory = serde_json::from_value(value).map_err(invalid)?; + if inventory.schema_version != 1 + || !lower_hex(&inventory.source_commit, 40) + || source.is_some_and(|source| source != inventory.source_commit) + { + return Err(invalid("inventory schema version or source does not match")); + } + let mut previous: Option<&str> = None; + let mut filenames = BTreeSet::new(); + for module in &inventory.modules { + if !safe_module_id(&module.id) + || module.filename != bundle_file_name(&module.id) + || !filenames.insert(&module.filename) + || previous.is_some_and(|id| id >= module.id.as_str()) + || module.size == 0 + || module.size > MAX_BUNDLE + || !lower_hex(&module.sha256, 64) + { + return Err(invalid( + "inventory module identity, order, size or digest is invalid", + )); + } + previous = Some(&module.id); + } + let found = inventory + .modules + .iter() + .map(|module| (module.id.clone(), module.size)) + .collect::>(); + check_bundle_set(expected, &found).map_err(invalid)?; + // Required arrays were parsed strictly, but are not composition evidence. + let _ = (&inventory.tools, &inventory.materials); + for module in &inventory.modules { + let _ = &module.contributions; + } + Ok(CheckedInventory { + inventory, + raw_sha256: hex::encode(Sha256::digest(&bytes)), + }) +} + +fn lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +// serde_json normally converts overflowing integer literals into floats. +// Reject those before parsing, including numbers in otherwise opaque values. +fn validate_numbers(text: &str) -> io::Result<()> { + let bytes = text.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'"' => { + index += 1; + while index < bytes.len() && bytes[index] != b'"' { + if bytes[index] == b'\\' { + index += 1; + } + index += 1; + } + } + b'-' | b'0'..=b'9' => { + let start = index; + while index < bytes.len() + && matches!(bytes[index], b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') + { + index += 1; + } + let number = &text[start..index]; + let value = number + .parse::() + .map_err(|_| invalid("invalid inventory number"))?; + if !value.is_finite() { + return Err(invalid("inventory number overflows")); + } + if value.fract() == 0.0 && value.abs() > 9_007_199_254_740_991.0 { + return Err(invalid("inventory contains an unsafe integer")); + } + if !number.contains(['.', 'e', 'E']) + && ((number.starts_with('-') && number.parse::().is_err()) + || (!number.starts_with('-') && number.parse::().is_err())) + { + return Err(invalid("inventory integer overflows")); + } + continue; + } + _ => {} + } + index += 1; + } + Ok(()) +} + +struct StrictValue(usize); + +impl<'de> DeserializeSeed<'de> for StrictValue { + type Value = Value; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for StrictValue { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("strict JSON with unique keys and depth at most 64") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(Number::from(value))) + } + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(Number::from(value))) + } + fn visit_f64(self, value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("nonfinite number")) + } + + fn visit_seq>(self, mut sequence: A) -> Result { + if self.0 >= 64 { + return Err(serde::de::Error::custom("JSON depth exceeds 64")); + } + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(StrictValue(self.0 + 1))? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map>(self, mut object: A) -> Result { + if self.0 >= 64 { + return Err(serde::de::Error::custom("JSON depth exceeds 64")); + } + let mut values = Map::new(); + while let Some(key) = object.next_key::()? { + if values.contains_key(&key) { + return Err(serde::de::Error::custom("duplicate JSON key")); + } + let value = object.next_value_seed(StrictValue(self.0 + 1))?; + values.insert(key, value); + } + Ok(Value::Object(values)) + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::fs; + use std::os::unix::fs::symlink; + + use serde_json::json; + use tempfile::tempdir; + + use super::*; + + #[test] + fn rejects_inventory_leaf_links_even_when_pointing_at_valid_bytes() { + let root = tempdir().expect("should create inventory fixture"); + let real = root.path().join("real.json"); + let link = root.path().join("inventory.json"); + fs::write( + &real, + serde_json::to_vec(&json!({ + "schema_version": 1, "source_commit": "a".repeat(40), + "modules": [], "tools": [], "materials": [] + })) + .expect("should serialize inventory"), + ) + .expect("should write inventory"); + symlink(&real, &link).expect("should link inventory"); + + let result = read_inventory(&link, &[], None); + + assert!( + result.is_err(), + "should reject links at the actual inventory read" + ); + } + + #[test] + fn declared_bundle_cap_is_enforced_by_inventory_parser() { + let root = tempdir().expect("should create inventory fixture"); + let path = root.path().join("inventory.json"); + const CONTRACT_LIMIT: u64 = 32 * 1024 * 1024; + for (size, accepted) in [(CONTRACT_LIMIT, true), (CONTRACT_LIMIT + 1, false)] { + fs::write( + &path, + serde_json::to_vec(&json!({ + "schema_version": 1, "source_commit": "a".repeat(40), + "modules": [{"id": "core", "filename": "tsjs-core.js", "size": size, + "sha256": "a".repeat(64), "contributions": []}], + "tools": [], "materials": [] + })) + .expect("should serialize inventory"), + ) + .expect("should write inventory"); + let result = read_inventory(&path, &["core".to_owned()], None); + if accepted { + assert!(result.is_ok(), "should accept exact declared bundle cap"); + } else { + let error = result + .err() + .expect("should reject oversized declared bundle"); + assert!( + error.to_string().contains("size"), + "should explain inventory size rejection: {error}" + ); + } + } + } +} diff --git a/crates/trusted-server-js/build/prebuilt.rs b/crates/trusted-server-js/build/prebuilt.rs new file mode 100644 index 0000000000..80cac5c6fd --- /dev/null +++ b/crates/trusted-server-js/build/prebuilt.rs @@ -0,0 +1,299 @@ +//! Copy checked prebuilt inputs through held directory handles. +//! +//! Cargo owns `OUT_DIR` and its parents. Other processes able to write that +//! private build directory are outside this input-validation trust boundary. +//! Source parents may contain links; a held directory descriptor anchors all +//! reads, and reopening the path after copying must resolve to the same inode. + +use std::collections::BTreeMap; +use std::ffi::{CStr, CString}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read as _, Write as _}; +use std::os::fd::{AsRawFd as _, FromRawFd as _}; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _, OpenOptionsExt as _}; +use std::path::Path; + +use sha2::{Digest as _, Sha256}; + +use super::bundle_set::{bundle_file_name, check_bundle_set, safe_module_id}; +use super::inventory::{Inventory, MAX_BUNDLE, invalid}; + +#[derive(Debug, Eq, PartialEq)] +struct Identity { + device: u64, + inode: u64, + size: u64, + modified: (i64, i64), + changed: (i64, i64), + links: u64, +} + +fn identity(file: &File) -> io::Result { + let metadata = file.metadata()?; + Ok(Identity { + device: metadata.dev(), + inode: metadata.ino(), + size: metadata.len(), + modified: (metadata.mtime(), metadata.mtime_nsec()), + changed: (metadata.ctime(), metadata.ctime_nsec()), + links: metadata.nlink(), + }) +} + +fn open_directory(path: &Path) -> io::Result { + // Components remove trailing separators and '.' without following links. + let leaf_path = path.components().collect::(); + OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&leaf_path) + .map_err(|error| invalid(format!("cannot open directory {}: {error}", path.display()))) +} + +fn open_file(directory: &File, name: &str) -> io::Result { + let name = CString::new(name).map_err(|_| invalid("unsafe bundle filename"))?; + // SAFETY: the descriptor and C string remain alive for the syscall; a + // successful descriptor is immediately transferred into an owning File. + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + return Err(invalid(format!( + "cannot open bundle {name:?}: {}", + io::Error::last_os_error() + ))); + } + // SAFETY: openat returned a fresh descriptor owned solely by this File. + let file = unsafe { File::from_raw_fd(descriptor) }; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(invalid(format!( + "bundle {name:?} must be a regular file with exactly one hard link" + ))); + } + Ok(file) +} + +struct DirectoryStream(*mut libc::DIR); + +impl Drop for DirectoryStream { + fn drop(&mut self) { + // SAFETY: this stream owns the successful fdopendir result. + unsafe { + libc::closedir(self.0); + } + } +} + +fn names(directory: &File, maximum: usize) -> io::Result> { + // Opening '.' creates a separate enumeration offset for every scan. + let dot = c"."; + // SAFETY: the descriptor and static C string are valid for this syscall. + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: descriptor is fresh; ownership passes to the stream on success. + let stream = unsafe { libc::fdopendir(descriptor) }; + if stream.is_null() { + let error = io::Error::last_os_error(); + // SAFETY: fdopendir failed and did not take ownership. + unsafe { + libc::close(descriptor); + } + return Err(error); + } + let stream = DirectoryStream(stream); + let mut result = Vec::new(); + loop { + clear_errno(); + // SAFETY: the stream is owned, live, and used on this thread only. + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(0) { + return Err(error); + } + break; + } + // SAFETY: readdir supplies a NUL-terminated name valid until next call. + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + let name = name + .to_str() + .map_err(|_| invalid("bundle filename must be UTF-8"))?; + if name != "." && name != ".." { + if result.len() >= maximum { + return Err(invalid(format!( + "unexpected extra bundle directory entry: {name}" + ))); + } + result.push(name.to_owned()); + } + } + result.sort(); + Ok(result) +} + +fn clear_errno() { + // SAFETY: these functions return the current thread's errno storage. + #[cfg(target_os = "macos")] + unsafe { + *libc::__error() = 0; + } + #[cfg(target_os = "linux")] + unsafe { + *libc::__errno_location() = 0; + } +} + +fn snapshot(directory: &File, expected: &[String]) -> io::Result> { + let mut snapshot = BTreeMap::new(); + let mut found = Vec::new(); + for name in names(directory, expected.len())? { + let id = name + .strip_prefix("tsjs-") + .and_then(|stem| stem.strip_suffix(".js")) + .filter(|id| safe_module_id(id)) + .ok_or_else(|| invalid(format!("unexpected or unsafe bundle filename: {name}")))?; + let file = open_file(directory, &name)?; + let identity = identity(&file)?; + found.push((id.to_owned(), identity.size)); + snapshot.insert(name, identity); + } + check_bundle_set(expected, &found).map_err(invalid)?; + Ok(snapshot) +} + +fn prepare_private(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path)?, + Ok(_) => { + return Err(invalid( + "private bundle destination must be a directory, never a link", + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + fs::DirBuilder::new().mode(0o700).create(path) +} + +fn copy_one(source: &mut File, destination: &Path, size: u64) -> io::Result { + let mut target = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(destination)?; + let mut hasher = Sha256::new(); + let mut reader = source.take(size + 1); + let mut buffer = [0; 64 * 1024]; + let mut length = 0u64; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + break; + } + length += count as u64; + if length > size { + return Err(invalid("inventory bundle size mismatch during copy")); + } + hasher.update(&buffer[..count]); + target.write_all(&buffer[..count])?; + } + if length != size { + return Err(invalid("inventory bundle size mismatch during copy")); + } + target.flush()?; + Ok(hex::encode(hasher.finalize())) +} + +pub(crate) fn copy_checked( + expected: &[String], + source_path: &Path, + destination: &Path, + inventory: &Inventory, + mut hook: impl FnMut(&Path, &Path), +) -> io::Result<()> { + let source = open_directory(source_path)?; + let source_identity = identity(&source)?; + let before = snapshot(&source, expected)?; + prepare_private(destination)?; + let mut digests = BTreeMap::new(); + for id in expected { + let name = bundle_file_name(id); + let mut file = open_file(&source, &name)?; + if before.get(&name) != Some(&identity(&file)?) { + return Err(invalid("source bundle identity changed before copy")); + } + let module = inventory + .modules + .iter() + .find(|module| module.id == *id) + .ok_or_else(|| invalid(format!("inventory missing bundle {name}")))?; + let size = module.size; + if size > MAX_BUNDLE { + return Err(invalid("bundle exceeds 32 MiB")); + } + let digest = copy_one(&mut file, &destination.join(&name), size)?; + if module.sha256 != digest { + return Err(invalid(format!("inventory bundle digest mismatch: {name}"))); + } + if before.get(&name) != Some(&identity(&file)?) { + return Err(invalid("source bundle changed during copy")); + } + digests.insert(name, (size, digest)); + } + // Crate-private deterministic race injection; production supplies a no-op. + hook(source_path, destination); + if snapshot(&source, expected)? != before + || identity(&source)? != source_identity + || identity(&open_directory(source_path)?)? != source_identity + { + return Err(invalid( + "source directory or bundle identity changed during copy", + )); + } + let private = open_directory(destination)?; + snapshot(&private, expected)?; + for (name, (size, digest)) in digests { + let mut file = open_file(&private, &name)?; + let mut bytes = Vec::new(); + (&mut file).take(size + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 != size || hex::encode(Sha256::digest(&bytes)) != digest { + return Err(invalid( + "private copied bundle does not match inventory input bytes", + )); + } + } + Ok(()) +} + +pub(crate) fn inventory_outside(source: &Path, inventory: &Path) -> io::Result<()> { + // Comparing canonical paths prevents aliases from placing metadata inside + // the exact bundle-only directory. The bytes are read separately once. + if inventory + .canonicalize()? + .starts_with(source.canonicalize()?) + { + return Err(invalid( + "inventory must be outside the exact bundle directory", + )); + } + // Reject a link at the inventory leaf as well as nonregular metadata. + let metadata = fs::symlink_metadata(inventory)?; + if !metadata.is_file() || metadata.nlink() != 1 || inventory.as_os_str().as_bytes().is_empty() { + return Err(invalid("inventory must be a regular single-link file")); + } + Ok(()) +} diff --git a/crates/trusted-server-js/lib/.gitignore b/crates/trusted-server-js/lib/.gitignore index 56c5a23dd6..faf0d13fbd 100644 --- a/crates/trusted-server-js/lib/.gitignore +++ b/crates/trusted-server-js/lib/.gitignore @@ -4,3 +4,4 @@ src/integrations/prebid/.external-generated-* # Serializes `npm ci` across concurrent cargo build scripts .tsjs-npm-ci.lock +.release-test-*/ diff --git a/crates/trusted-server-js/lib/.prettierignore b/crates/trusted-server-js/lib/.prettierignore index 88957b43a4..73f3fc9b78 100644 --- a/crates/trusted-server-js/lib/.prettierignore +++ b/crates/trusted-server-js/lib/.prettierignore @@ -1,4 +1,5 @@ node_modules dist coverage +.release-test-*/ trace-assets/** diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 9745d06d95..28c8e78e30 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -20,16 +20,13 @@ import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { build } from 'vite'; import { buildTraceAssets } from './build-trace.mjs'; import { moduleBuildOptions } from './build-module-options.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const srcDir = path.resolve(__dirname, 'src'); -const distDir = resolveOutDir(process.argv.slice(2)); -const integrationsDir = path.join(srcDir, 'integrations'); /** Resolve `--out-dir ` (relative to the cwd), defaulting to ../dist. */ function resolveOutDir(args) { @@ -44,54 +41,97 @@ function resolveOutDir(args) { return path.resolve(value); } -// Remove only the bundles this script writes, so a wrong --out-dir cannot -// delete unrelated files. -fs.mkdirSync(distDir, { recursive: true }); -for (const name of fs.readdirSync(distDir)) { - if (name.startsWith('tsjs-') && name.endsWith('.js')) { - fs.rmSync(path.join(distDir, name)); +/** Build the discovered modules; recording is opt-in and cannot change bundle bytes. */ +export async function buildAll({ + outDir, + composition, + sourceDir = path.resolve(__dirname, 'src'), +}) { + const srcDir = sourceDir; + const distDir = path.resolve(outDir); + const integrationsDir = path.join(srcDir, 'integrations'); + const records = []; + + // Remove only the bundles this script writes, so a wrong --out-dir cannot + // delete unrelated files. + fs.mkdirSync(distDir, { recursive: true }); + for (const name of fs.readdirSync(distDir)) { + if (name.startsWith('tsjs-') && name.endsWith('.js')) { + fs.rmSync(path.join(distDir, name)); + } } -} -// Discover integration modules: directories in src/integrations/ with index.ts -const integrationModules = fs.existsSync(integrationsDir) - ? fs - .readdirSync(integrationsDir) - .filter((name) => { - const fullPath = path.join(integrationsDir, name); - return ( - fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) - ); - }) - .sort() - : []; - -console.log('[build-all] Discovered integrations:', integrationModules); - -/** Build a single module as a self-contained IIFE. */ -async function buildModule(name, entryPath) { - const outFile = `tsjs-${name}.js`; - console.log(`[build-all] Building ${outFile} from ${path.relative(__dirname, entryPath)}`); - - await build(moduleBuildOptions({ name, entryPath, outDir: distDir })); - - console.log(`[build-all] Built ${outFile}`); -} + // Discover integration modules: directories in src/integrations/ with index.ts + const integrationModules = fs.existsSync(integrationsDir) + ? fs + .readdirSync(integrationsDir) + .filter((name) => { + const fullPath = path.join(integrationsDir, name); + return ( + fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) + ); + }) + .sort() + : []; + + console.log('[build-all] Discovered integrations:', integrationModules); -// Build core first (synchronously), then all integrations in parallel -await buildModule('core', path.join(srcDir, 'core', 'index.ts')); + /** Build a single module as a self-contained IIFE. */ + async function buildModule(name, entryPath) { + const outFile = `tsjs-${name}.js`; + console.log(`[build-all] Building ${outFile} from ${path.relative(__dirname, entryPath)}`); + + let contributions; + const recording = composition + ? { + ...composition, + onContributions: (rows) => { + contributions = rows; + }, + } + : undefined; + await build(moduleBuildOptions({ name, entryPath, outDir: distDir, composition: recording })); + records.push({ id: name, filename: outFile, contributions }); + + console.log(`[build-all] Built ${outFile}`); + } -await Promise.all( - integrationModules.map((name) => buildModule(name, path.join(integrationsDir, name, 'index.ts'))) -); + // Build core first (synchronously), then all integrations in parallel + await buildModule('core', path.join(srcDir, 'core', 'index.ts')); -await buildTraceAssets(distDir); + await Promise.all( + integrationModules.map((name) => + buildModule(name, path.join(integrationsDir, name, 'index.ts')) + ) + ); -// List all built files -const builtFiles = fs - .readdirSync(distDir) - .filter((f) => f.startsWith('tsjs-') && f.endsWith('.js')) - .sort(); + // List all built files + const builtFiles = fs + .readdirSync(distDir) + .filter((f) => f.startsWith('tsjs-') && f.endsWith('.js')) + .sort(); -console.log('[build-all] Built files:', builtFiles); -console.log(`[build-all] Total: ${builtFiles.length} modules`); + console.log('[build-all] Built files:', builtFiles); + console.log(`[build-all] Total: ${builtFiles.length} modules`); + + return records.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} + +// Node without import.meta.main (before 22.18 or 24.2) would skip the build silently. +if (import.meta.main === undefined) { + throw new Error( + '[build-all] requires a Node.js with import.meta.main (22.18+ or 24.2+); use the .tool-versions version' + ); +} +if (import.meta.main) { + if ( + !process.argv[1] || + import.meta.url !== pathToFileURL(fs.realpathSync(process.argv[1])).href + ) { + throw new Error('build-all entrypoint identity check failed'); + } + const outDir = resolveOutDir(process.argv.slice(2)); + await buildAll({ outDir }); + // The trace viewer is independent of the bundles, so release collection skips it. + await buildTraceAssets(outDir); +} diff --git a/crates/trusted-server-js/lib/build-module-options.mjs b/crates/trusted-server-js/lib/build-module-options.mjs index 64b78a3fb7..be8b667871 100644 --- a/crates/trusted-server-js/lib/build-module-options.mjs +++ b/crates/trusted-server-js/lib/build-module-options.mjs @@ -1,12 +1,17 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { compositionPlugin, releaseBuild } from './release-composition.mjs'; + const libDir = path.dirname(fileURLToPath(import.meta.url)); /** Production Vite options for one independently bundled tsjs IIFE. */ -export function moduleBuildOptions({ name, entryPath, outDir }) { +export function moduleBuildOptions({ name, entryPath, outDir, composition }) { + const release = releaseBuild(); return { configFile: false, + ...(release ? { mode: 'production', envDir: false } : {}), + ...(composition ? { plugins: [compositionPlugin({ name, ...composition })] } : {}), root: libDir, build: { emptyOutDir: false, diff --git a/crates/trusted-server-js/lib/eslint.config.js b/crates/trusted-server-js/lib/eslint.config.js index 2d6b83f03e..f80f1d541f 100644 --- a/crates/trusted-server-js/lib/eslint.config.js +++ b/crates/trusted-server-js/lib/eslint.config.js @@ -9,7 +9,7 @@ import unicorn from 'eslint-plugin-unicorn'; export default [ // Files/folders to ignore { - ignores: ['node_modules', 'dist', 'coverage', 'trace-assets/**'], + ignores: ['node_modules', 'dist', 'coverage', '.release-test-*/', 'trace-assets/**'], }, // Base JS recommended js.configs.recommended, diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 6b40f82a63..8e2c1a71b3 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -13,8 +13,8 @@ "test:watch": "vitest", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint --fix . --max-warnings=0", - "format": "prettier --check \"**/*.{ts,tsx,js,json,css,md}\"", - "format:write": "prettier --write \"**/*.{ts,tsx,js,json,css,md}\"" + "format": "prettier --check \"**/*.{ts,tsx,js,mjs,json,css,md}\"", + "format:write": "prettier --write \"**/*.{ts,tsx,js,mjs,json,css,md}\"" }, "dependencies": { "prebid.js": "^10.26.0" diff --git a/crates/trusted-server-js/lib/release-composition.mjs b/crates/trusted-server-js/lib/release-composition.mjs new file mode 100644 index 0000000000..7af052b226 --- /dev/null +++ b/crates/trusted-server-js/lib/release-composition.mjs @@ -0,0 +1,661 @@ +/** Conservative Rollup evidence; rendered modules can lose more code in esbuild. */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +export const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); +const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0); +const relative = (root, file) => path.relative(root, file).split(path.sep).join('/'); +const inside = (root, file) => file === root || file.startsWith(`${root}${path.sep}`); +const MAX_JSON = 1024 * 1024; + +/** Parse bounded UTF-8 JSON without duplicate keys or non-finite numbers. */ +export function strictJson(bytes) { + if (bytes.length > MAX_JSON) throw new Error('JSON exceeds 1 MiB'); + const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); + let offset = 0; + const space = () => { + while (/[\t\n\r ]/.test(text[offset] ?? '\0')) offset++; + }; + const string = () => { + const start = offset++; + while (offset < text.length) { + const character = text[offset++]; + if (character === '\\') offset++; + else if (character === '"') { + const parsed = JSON.parse(text.slice(start, offset)); + if (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + if (depth > 64) throw new Error('JSON nesting exceeds 64'); + space(); + const character = text[offset]; + if (character === '"') return string(); + if (character === '{' || character === '[') { + if (depth >= 64) throw new Error('JSON nesting exceeds 64'); + offset++; + const object = character === '{'; + const result = object ? Object.create(null) : []; + const end = object ? '}' : ']'; + space(); + if (text[offset] === end) { + offset++; + return result; + } + for (;;) { + space(); + let key; + if (object) { + if (text[offset] !== '"') throw new Error('Expected JSON key'); + key = string(); + if (Object.hasOwn(result, key)) throw new Error(`Duplicate JSON key: ${key}`); + space(); + if (text[offset++] !== ':') throw new Error('Expected JSON colon'); + } + const parsed = value(depth + 1); + if (object) result[key] = parsed; + else result.push(parsed); + space(); + const delimiter = text[offset++]; + if (delimiter === end) return result; + if (delimiter !== ',') throw new Error('Expected JSON delimiter'); + } + } + const token = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec( + text.slice(offset) + )?.[0]; + if (!token) throw new Error('Invalid JSON value'); + offset += token.length; + const parsed = JSON.parse(token); + if ( + typeof parsed === 'number' && + (!Number.isFinite(parsed) || (Number.isInteger(parsed) && !Number.isSafeInteger(parsed))) + ) + throw new Error('Non-finite JSON number'); + return parsed; + }; + const result = value(0); + space(); + if (offset !== text.length) throw new Error('Trailing JSON data'); + return result; +} + +/** Derive release settings from the existing build control only. */ +export function releaseBuild(env = process.env) { + if (!Object.hasOwn(env, 'TRUSTED_SERVER__RELEASE_BUILD')) return false; + if (Object.hasOwn(env, 'TSJS_SKIP_BUILD')) throw new Error('TSJS_SKIP_BUILD is forbidden'); + if (env.TRUSTED_SERVER__RELEASE_BUILD !== '1') + throw new Error('Release build control must be exactly 1'); + if (!/^[a-f0-9]{40}$/.test(env.TRUSTED_SERVER__GIT_VERSION ?? '')) + throw new Error('Release requires full source commit'); + return true; +} + +/** Join real installed locations to exact package-lock package identities. */ +export function installedOrigins({ repoRoot, lockFile, installRoot = path.dirname(lockFile) }) { + repoRoot = fs.realpathSync(repoRoot); + installRoot = fs.realpathSync(installRoot); + const lock = strictJson(readBounded(lockFile)); + if ( + lock.lockfileVersion !== 3 || + !lock.packages || + typeof lock.packages !== 'object' || + Array.isArray(lock.packages) + ) + throw new Error('Expected package-lock v3 packages'); + const packages = new Map(); + const realRoots = new Map(); + for (const [lockPath, record] of Object.entries(lock.packages)) { + if (lockPath === '') continue; + if ( + !/^(?:node_modules\/(?:@[a-zA-Z0-9._-]+\/)?[a-zA-Z0-9._-]+)(?:\/node_modules\/(?:@[a-zA-Z0-9._-]+\/)?[a-zA-Z0-9._-]+)*$/.test( + lockPath + ) + ) + throw new Error(`Unknown lock source: ${lockPath}`); + const root = path.join(installRoot, lockPath); + if (!fs.existsSync(root)) continue; + const real = fs.realpathSync(root); + if (real !== root || !inside(repoRoot, real)) + throw new Error(`Installed package symlink escape or alias: ${lockPath}`); + if (realRoots.has(real)) throw new Error(`Duplicate installed identity: ${lockPath}`); + realRoots.set(real, lockPath); + packages.set(lockPath, { root, record }); + } + function packageIdentity(lockPath) { + const installed = packages.get(lockPath); + if (!installed) throw new Error(`Missing installed package: ${lockPath}`); + const { root, record } = installed; + if (Object.hasOwn(record, 'link') || typeof record.resolved !== 'string') + throw new Error(`Unsupported lock archive source: ${lockPath}`); + let archive; + try { + archive = new URL(record.resolved); + } catch { + throw new Error(`Unsupported lock archive source: ${lockPath}`); + } + if ( + archive.protocol !== 'https:' || + archive.hostname !== 'registry.npmjs.org' || + archive.port || + archive.username || + archive.password || + archive.search || + archive.hash || + archive.href !== record.resolved || + !archive.pathname.endsWith('.tgz') + ) + throw new Error(`Unsupported lock archive source: ${lockPath}`); + const manifest = strictJson(readBounded(path.join(root, 'package.json'))); + const name = lockPath.slice(lockPath.lastIndexOf('node_modules/') + 'node_modules/'.length); + if (manifest.name !== name || (record.name && record.name !== name)) + throw new Error(`Package name mismatch: ${lockPath}`); + if (!record.version || manifest.version !== record.version) + throw new Error(`Package version mismatch: ${lockPath}`); + if (!/^sha512-[A-Za-z0-9+/]{86}==$/.test(record.integrity ?? '')) + throw new Error(`Missing or unsupported package integrity: ${lockPath}`); + return { + kind: 'npm', + lock_path: lockPath, + package_name: name, + package_version: manifest.version, + integrity: record.integrity, + }; + } + function origin(id) { + const [file, ...queries] = id.split('?'); + const transform = + queries.length === 0 + ? 'identity' + : queries.length === 1 && ['raw', 'inline'].includes(queries[0]) + ? queries[0] + : null; + if (!transform) throw new Error(`Unknown module query: ${id}`); + if (!path.isAbsolute(file)) throw new Error(`Unknown module source: ${id}`); + const real = fs.realpathSync(file); + if (!inside(repoRoot, real)) throw new Error(`Source symlink escape: ${id}`); + const bytes = readBounded(real, 32 * 1024 * 1024); + if (bytes.length > 32 * 1024 * 1024) throw new Error('Source exceeds 32 MiB'); + let directory = path.dirname(real); + while (inside(installRoot, directory)) { + const lockPath = relative(installRoot, directory); + if (packages.has(lockPath)) { + if (!inside(directory, real)) throw new Error(`Package source symlink escape: ${id}`); + return { + ...packageIdentity(lockPath), + path: relative(directory, real), + transform, + sha256: sha256(bytes), + }; + } + if ( + fs.existsSync(path.join(directory, 'package.json')) && + /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/]+$/.test(lockPath) + ) + throw new Error(`Unmapped installed ownership: ${id}`); + if (directory === installRoot) break; + directory = path.dirname(directory); + } + if (relative(repoRoot, real).split('/').includes('node_modules')) + throw new Error(`Unmapped installed ownership: ${id}`); + return { kind: 'source', path: relative(repoRoot, real), transform, sha256: sha256(bytes) }; + } + return { repoRoot, installRoot, packages, packageIdentity, origin }; +} + +/** Observe exactly one IIFE's rendered modules without altering emitted output. */ +export function compositionPlugin({ name, owners: checkedOwners, ...settings }) { + const owners = checkedOwners ?? installedOrigins(settings); + return { + name: 'trusted-server-release-composition', + generateBundle(options, bundle) { + const outputs = Object.values(bundle); + if ( + options.format !== 'iife' || + outputs.length !== 1 || + outputs[0].type !== 'chunk' || + !outputs[0].isEntry || + outputs[0].fileName !== `tsjs-${name}.js` + ) + throw new Error('Expected exactly one IIFE chunk with no assets'); + const contributions = []; + for (const [id, rendered] of Object.entries(outputs[0].modules)) { + if (rendered.renderedLength === 0) continue; + let contribution; + if (!id.startsWith('\0')) contribution = owners.origin(id); + else { + let plugin; + let input; + let stableId = id; + if (id === '\0commonjsHelpers.js') plugin = 'commonjs'; + else if (id.startsWith('\0') && id.endsWith('?commonjs-es-import')) { + plugin = 'commonjs'; + input = owners.origin(id.slice(1, -'?commonjs-es-import'.length)); + stableId = `\0${input.kind === 'npm' ? `${input.lock_path}/` : ''}${input.path}?commonjs-es-import`; + } else if (id === '\0vite/preload-helper.js') plugin = 'vite:build-import-analysis'; + else throw new Error(`Unknown virtual producer: ${id}`); + owners.packageIdentity('node_modules/vite'); + const code = this.getModuleInfo(id)?.code; + if (typeof code !== 'string') throw new Error(`Missing generated code: ${id}`); + contribution = { + kind: 'generated', + id: stableId, + plugin, + owner_lock_path: 'node_modules/vite', + generated_sha256: sha256(code), + ...(input ? { input } : {}), + }; + } + contributions.push({ + ...contribution, + stage: 'rollup-rendered', + rendered_length: rendered.renderedLength, + }); + } + contributions.sort((a, b) => compare(JSON.stringify(a), JSON.stringify(b))); + settings.onContributions(contributions); + }, + }; +} + +function stableFile(file, consume) { + const descriptor = fs.openSync( + file, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK + ); + try { + const before = fs.fstatSync(descriptor); + if (!before.isFile()) throw new Error(`Expected regular file: ${file}`); + const result = consume(descriptor, before.size, before.mode); + const after = fs.fstatSync(descriptor); + const current = fs.lstatSync(file); + if ( + before.dev !== current.dev || + before.ino !== current.ino || + before.size !== after.size || + before.size !== current.size || + before.mode !== after.mode || + before.mode !== current.mode || + before.mtimeMs !== after.mtimeMs || + before.mtimeMs !== current.mtimeMs || + before.ctimeMs !== after.ctimeMs || + before.ctimeMs !== current.ctimeMs + ) + throw new Error(`File changed while measured: ${file}`); + return result; + } finally { + fs.closeSync(descriptor); + } +} + +/** Read at most the specified input budget through a held regular-file descriptor. */ +export function readBounded(file, maximum = MAX_JSON) { + return stableFile(file, (descriptor, size) => { + if (size > maximum) throw new Error(`File exceeds size budget: ${file}`); + const bytes = Buffer.alloc(size + 1); + let count = 0; + while (count < bytes.length) { + const read = fs.readSync(descriptor, bytes, count, bytes.length - count, null); + if (!read) break; + count += read; + } + if (count !== size) throw new Error(`File changed or exceeds size budget: ${file}`); + return bytes.subarray(0, count); + }); +} + +/** Hash file bytes with fixed-size buffers, preserving the held-file identity. */ +export function fileDigest(file) { + return stableFile(file, (descriptor, size) => { + const digest = crypto.createHash('sha256'); + const buffer = Buffer.alloc(64 * 1024); + let count = 0; + for (;;) { + const read = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (!read) break; + count += read; + if (count > size) throw new Error(`File grew while measured: ${file}`); + digest.update(buffer.subarray(0, read)); + } + if (count !== size) throw new Error(`File changed while measured: ${file}`); + return digest.digest('hex'); + }); +} + +/** Hash sorted relative paths, types, lengths, bytes and normalized executable modes. */ +export function installedTreeHash(root, excludedRoots = new Set()) { + const digest = crypto.createHash('sha256'); + digest.update('trusted-server-installed-tree-v1\n'); + function visit(directory) { + for (const name of fs.readdirSync(directory).sort(compare)) { + const file = path.join(directory, name); + if (excludedRoots.has(file)) continue; + const stat = fs.lstatSync(file); + const filename = relative(root, file); + if (stat.isDirectory()) { + digest.update(`${JSON.stringify([filename, 'directory'])}\n`); + visit(file); + } else if (stat.isFile()) { + stableFile(file, (descriptor, size, mode) => { + digest.update(`${JSON.stringify([filename, 'file', size, Boolean(mode & 0o111)])}\n`); + const buffer = Buffer.alloc(64 * 1024); + let count = 0; + for (;;) { + const read = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (!read) break; + count += read; + if (count > size) throw new Error(`Tree file grew: ${file}`); + digest.update(buffer.subarray(0, read)); + } + if (count !== size || size !== stat.size) throw new Error(`Tree file changed: ${file}`); + }); + digest.update('\n'); + } else if (stat.isSymbolicLink()) { + const target = fs.readlinkSync(file); + if (path.isAbsolute(target) || !inside(root, fs.realpathSync(file))) + throw new Error(`Installed tree symlink escape: ${file}`); + digest.update(`${JSON.stringify([filename, 'symlink', target])}\n`); + } else throw new Error(`Unsupported installed tree entry: ${file}`); + } + } + visit(root); + return digest.digest('hex'); +} + +/** Measure installed packages independently of their lockfile archive integrity. */ +export function installedMaterials(owners) { + const excluded = new Set([...owners.packages.values()].map(({ root }) => root)); + return [...owners.packages.keys()].sort(compare).map((lockPath) => { + const identity = owners.packageIdentity(lockPath); + return { + kind: 'npm-package', + id: `npm:${lockPath}`, + lock_path: lockPath, + name: identity.package_name, + version: identity.package_version, + integrity: identity.integrity, + installed_tree_sha256: installedTreeHash(owners.packages.get(lockPath).root, excluded), + }; + }); +} + +const hashPattern = /^[a-f0-9]{64}$/; +function fields(record, keys) { + if ( + !record || + typeof record !== 'object' || + Array.isArray(record) || + Object.keys(record).sort().join(',') !== [...keys].sort().join(',') + ) + throw new Error('Unexpected preparation fields'); +} +function digest(value) { + if (typeof value !== 'string' || !hashPattern.test(value)) + throw new Error('Invalid preparation digest'); +} +function safePath(value) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + value.split('/').some((part) => part === '.' || part === '..' || part === '') + ) + throw new Error('Unsafe preparation path'); +} + +/** Declare expected registry-install hooks; these declarations are not execution observations. */ +export function expectedLifecycleEvents({ installRoot, packages }) { + const events = []; + for (const material of packages) { + const root = path.join(installRoot, material.lock_path); + const manifest = strictJson(readBounded(path.join(root, 'package.json'))); + if ( + fs.existsSync(path.join(root, 'binding.gyp')) && + !manifest.scripts?.install && + !manifest.scripts?.preinstall + ) + throw new Error(`Unsupported implicit lifecycle install: ${material.lock_path}`); + for (const event of ['preinstall', 'install', 'postinstall']) { + const script = manifest.scripts?.[event]; + if (script === undefined) continue; + if (typeof script !== 'string' || !script) + throw new Error('Invalid declared lifecycle script'); + events.push({ + kind: 'lifecycle-event', + id: `lifecycle:${material.lock_path}:${event}`, + package_material_id: material.id, + event, + script_sha256: sha256(script), + }); + } + } + return events; +} + +/** Check preparation observations; this does not authenticate acquisition or lifecycle execution. */ +export function validatePreparation(prepared, { installRoot, packages }) { + fields(prepared, [ + 'schema_version', + 'tools', + 'distributions', + 'packages', + 'lifecycle_events', + 'lifecycle_outputs', + ]); + if ( + prepared.schema_version !== 1 || + !['tools', 'distributions', 'packages', 'lifecycle_events', 'lifecycle_outputs'].every((key) => + Array.isArray(prepared[key]) + ) + ) + throw new Error('Expected preparation version 1 arrays'); + const materials = []; + const identities = new Set(); + function add(material) { + if (typeof material.id !== 'string' || !material.id || identities.has(material.id)) + throw new Error('Duplicate or invalid preparation identity'); + identities.add(material.id); + materials.push(material); + } + const measured = new Map(packages.map((record) => [record.id, record])); + for (const material of prepared.packages) { + fields(material, [ + 'kind', + 'id', + 'lock_path', + 'name', + 'version', + 'integrity', + 'installed_tree_sha256', + ]); + if (material.kind !== 'npm-package' || material.id !== `npm:${material.lock_path}`) + throw new Error('Invalid package material identity'); + const actual = measured.get(material.id); + if (!actual || Object.keys(material).some((key) => material[key] !== actual[key])) + throw new Error(`Installed package material mismatch: ${material.id}`); + add(material); + } + if (prepared.packages.length !== packages.length) + throw new Error('Incomplete prepared package set'); + for (const distribution of prepared.distributions) { + fields(distribution, ['material', 'path']); + const { material, path: file } = distribution; + fields(material, ['kind', 'id', 'name', 'version', 'sha256']); + if ( + material.kind !== 'tool-distribution' || + typeof material.name !== 'string' || + !material.name || + typeof material.version !== 'string' || + !material.version + ) + throw new Error('Invalid tool distribution'); + digest(material.sha256); + if (fileDigest(file) !== material.sha256) + throw new Error(`Tool distribution digest mismatch: ${material.id}`); + add(material); + } + const expectedEvents = new Set( + expectedLifecycleEvents({ installRoot, packages }).map((event) => + JSON.stringify([event.package_material_id, event.event, event.script_sha256]) + ) + ); + const events = new Set(); + for (const event of prepared.lifecycle_events) { + fields(event, ['kind', 'id', 'package_material_id', 'event', 'script_sha256']); + const owner = measured.get(event.package_material_id); + if ( + event.kind !== 'lifecycle-event' || + !owner || + !['preinstall', 'install', 'postinstall', 'prepare'].includes(event.event) + ) + throw new Error('Invalid lifecycle event join'); + digest(event.script_sha256); + const manifest = strictJson( + readBounded(path.join(installRoot, owner.lock_path, 'package.json')) + ); + const script = manifest.scripts?.[event.event]; + if (typeof script !== 'string' || sha256(script) !== event.script_sha256) + throw new Error('Lifecycle script digest mismatch'); + const identity = JSON.stringify([event.package_material_id, event.event, event.script_sha256]); + if (events.has(identity)) throw new Error('Duplicate lifecycle event'); + if (!expectedEvents.has(identity)) throw new Error('Unexpected lifecycle event'); + events.add(identity); + add(event); + } + if (events.size !== expectedEvents.size) + throw new Error('Missing expected lifecycle observations'); + const packageRoots = packages + .map((material) => ({ + id: material.id, + root: fs.realpathSync(path.join(installRoot, material.lock_path)), + })) + .sort((first, second) => second.root.length - first.root.length); + for (const output of prepared.lifecycle_outputs) { + fields(output, [ + 'kind', + 'id', + 'package_material_id', + 'event', + 'script_sha256', + 'path', + 'size', + 'sha256', + ]); + if ( + output.kind !== 'lifecycle-output' || + !measured.has(output.package_material_id) || + !['preinstall', 'install', 'postinstall', 'prepare'].includes(output.event) + ) + throw new Error('Invalid lifecycle output join'); + digest(output.script_sha256); + digest(output.sha256); + safePath(output.path); + if (!Number.isSafeInteger(output.size) || output.size < 0 || output.size > 32 * 1024 * 1024) + throw new Error('Invalid lifecycle size'); + const owner = measured.get(output.package_material_id); + const ownerRoot = path.join(installRoot, owner.lock_path); + const manifest = strictJson(readBounded(path.join(ownerRoot, 'package.json'))); + const script = manifest.scripts?.[output.event]; + if (typeof script !== 'string' || sha256(script) !== output.script_sha256) + throw new Error('Lifecycle script digest mismatch'); + const file = path.join(installRoot, output.path); + if (!inside(ownerRoot, file)) throw new Error('Lifecycle output owner mismatch'); + const realFile = fs.realpathSync(file); + if (!inside(fs.realpathSync(installRoot), realFile)) throw new Error('Lifecycle output escape'); + if ( + !inside(fs.realpathSync(ownerRoot), realFile) || + packageRoots.find(({ root }) => inside(root, realFile))?.id !== output.package_material_id + ) + throw new Error('Lifecycle output owner mismatch'); + if ( + !events.has(JSON.stringify([output.package_material_id, output.event, output.script_sha256])) + ) + throw new Error('Lifecycle output has no observed event'); + const bytes = readBounded(file, 32 * 1024 * 1024); + if (bytes.length !== output.size || sha256(bytes) !== output.sha256) + throw new Error('Lifecycle output digest mismatch'); + add(output); + } + const tools = []; + const names = new Set(); + for (const tool of prepared.tools) { + fields(tool, ['name', 'version', 'role', 'material_id']); + const material = materials.find((record) => record.id === tool.material_id); + if ( + !material || + material.kind !== 'tool-distribution' || + material.name !== tool.name || + material.version !== tool.version || + !['runtime', 'package-manager', 'bundler', 'minifier', 'test-runner'].includes(tool.role) || + names.has(tool.name) + ) + throw new Error('Invalid or duplicate tool material join'); + names.add(tool.name); + tools.push(tool); + } + return { + materials: materials.sort((a, b) => compare(a.id, b.id)), + tools: tools.sort((a, b) => compare(a.name, b.name)), + }; +} + +/** Measure the actual tool entry files and native executables; no host authentication is claimed. */ +export function actualToolFiles({ installRoot }) { + const npmRoot = path.resolve(path.dirname(process.execPath), '../lib/node_modules/npm'); + const tools = [ + { name: 'node', role: 'runtime', version: process.versions.node, path: process.execPath }, + { + name: 'npm', + role: 'package-manager', + version: strictJson(readBounded(path.join(npmRoot, 'package.json'))).version, + path: path.join(npmRoot, 'bin/npm-cli.js'), + }, + ]; + const modules = [ + ['vite', 'bundler', 'dist/node/index.js'], + ['rollup', 'bundler', 'dist/es/rollup.js'], + ['esbuild', 'minifier', 'lib/main.js'], + ['vitest', 'test-runner', 'vitest.mjs'], + ]; + for (const [name, role, entry] of modules) { + const root = path.join(installRoot, 'node_modules', name); + tools.push({ + name, + role, + version: strictJson(readBounded(path.join(root, 'package.json'))).version, + path: path.join(root, entry), + }); + } + if (!['darwin', 'linux'].includes(process.platform) || !['arm64', 'x64'].includes(process.arch)) + throw new Error('Unsupported native tool platform'); + const platform = `${process.platform}-${process.arch}`; + const esbuildRoot = path.join(installRoot, 'node_modules/@esbuild', platform); + const rollupSuffix = + process.platform === 'linux' + ? process.report.getReport().header.glibcVersionRuntime + ? '-gnu' + : '-musl' + : ''; + const rollupPlatform = `${platform}${rollupSuffix}`; + const rollupRoot = path.join(installRoot, 'node_modules/@rollup', `rollup-${rollupPlatform}`); + tools.push({ + name: `esbuild:${platform}`, + role: 'minifier', + version: strictJson(readBounded(path.join(esbuildRoot, 'package.json'))).version, + path: path.join(esbuildRoot, 'bin/esbuild'), + }); + tools.push({ + name: `rollup:${platform}`, + role: 'bundler', + version: strictJson(readBounded(path.join(rollupRoot, 'package.json'))).version, + path: path.join(rollupRoot, `rollup.${rollupPlatform}.node`), + }); + return tools.map((tool) => ({ ...tool, sha256: fileDigest(tool.path) })); +} diff --git a/crates/trusted-server-js/lib/test/observe-npm.test.mjs b/crates/trusted-server-js/lib/test/observe-npm.test.mjs new file mode 100644 index 0000000000..d3df88ab72 --- /dev/null +++ b/crates/trusted-server-js/lib/test/observe-npm.test.mjs @@ -0,0 +1,427 @@ +// @vitest-environment node +import crypto from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + normalizeEsbuildHardlink, + parseRequest, + sealObservation, + snapshotTree, +} from '../../../../scripts/release/observe-npm.mjs'; + +const observer = pathToFileURL( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../scripts/release/observe-npm.mjs' + ) +).href; +const npmRoot = path.resolve(path.dirname(process.execPath), '../lib/node_modules/npm'); +const temporary = []; +function fixture(name = 'esbuild') { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ts-observer-'))); + temporary.push(root); + const install = path.join(root, 'installation'); + const owner = path.join(install, 'node_modules', name); + fs.mkdirSync(owner, { recursive: true }); + const version = name === 'esbuild' ? '0.27.3' : '3.48.0'; + const command = + name === 'esbuild' ? 'node install.js' : 'node -e "try{require(\'./postinstall\')}catch(e){}"'; + fs.writeFileSync( + path.join(install, 'package.json'), + JSON.stringify({ name: 'example-root', version: '1.0.0' }) + ); + fs.writeFileSync( + path.join(install, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { '': {}, [`node_modules/${name}`]: { version } }, + }) + ); + const pkg = { name, version, scripts: { postinstall: command } }; + fs.writeFileSync(path.join(owner, 'package.json'), JSON.stringify(pkg)); + return { root, install, owner, pkg }; +} +function executeHook(value, body, { second = false, shadow = false, native } = {}) { + fs.writeFileSync( + path.join(value.owner, value.pkg.name === 'esbuild' ? 'install.js' : 'postinstall.js'), + body + ); + if (shadow) { + const bin = path.join(value.owner, 'node_modules', '.bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'node'), '#!/bin/sh\nexit 91\n', { mode: 0o755 }); + } + const script = ` + import { createRequire } from 'node:module'; + import { installLifecycleObserver } from ${JSON.stringify(observer)}; + const require = createRequire(import.meta.url); + const [npmRoot, installRoot, owner, second, native] = process.argv.slice(1); + const state = installLifecycleObserver({ npmRoot, installRoot, nodePath: process.execPath, nativeEsbuild: JSON.parse(native) }); + const run = require(npmRoot + '/node_modules/@npmcli/run-script/lib/run-script.js'); + const pkg = require(owner + '/package.json'); + let caught = false; + for (let index = 0; index < (second === 'true' ? 2 : 1); index++) { + try { await run({ event: 'postinstall', path: owner, pkg, stdio: 'inherit' }); } catch { caught = true; } + } + console.log('OBSERVER_TEST_RESULT ' + JSON.stringify({ caught, failed: state.failed(), events: state.events, normalizations: state.normalizations })); + `; + const child = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + script, + npmRoot, + value.install, + value.owner, + String(second), + JSON.stringify(native ?? null), + ], + { + env: { + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`, + HOME: value.root, + TMPDIR: value.root, + }, + encoding: 'utf8', + input: 'private parent input', + timeout: 15000, + maxBuffer: 1024 * 1024, + } + ); + expect(child.status, child.stderr).toBe(0); + const result = child.stdout.split('\n').find((line) => line.startsWith('OBSERVER_TEST_RESULT ')); + expect(result, child.stderr).toBeTruthy(); + return JSON.parse(result.slice('OBSERVER_TEST_RESULT '.length)); +} + +function executeFreshCi({ optional, failHook }) { + const value = fixture(); + const original = path.join(value.root, 'original'); + const packaged = path.join(original, 'package'); + fs.mkdirSync(packaged, { recursive: true }); + fs.copyFileSync(path.join(value.owner, 'package.json'), path.join(packaged, 'package.json')); + fs.writeFileSync(path.join(packaged, 'install.js'), failHook ? 'process.exit(7);' : ''); + const archive = path.join(value.root, 'original.tgz'); + execFileSync('tar', ['czf', archive, '-C', original, 'package']); + const integrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(archive)).digest('base64')}`; + const pkg = { + name: 'example-root', + version: '1.0.0', + [optional ? 'optionalDependencies' : 'dependencies']: { esbuild: '0.27.3' }, + }; + fs.writeFileSync(path.join(value.install, 'package.json'), JSON.stringify(pkg)); + fs.writeFileSync( + path.join(value.install, 'package-lock.json'), + JSON.stringify({ + name: pkg.name, + version: pkg.version, + lockfileVersion: 3, + requires: true, + packages: { + '': pkg, + 'node_modules/esbuild': { + version: '0.27.3', + resolved: 'https://registry.example.com/esbuild/-/esbuild-0.27.3.tgz', + integrity, + hasInstallScript: true, + ...(optional ? { optional: true } : {}), + }, + }, + }) + ); + fs.rmSync(path.join(value.install, 'node_modules'), { recursive: true }); + const cache = path.join(value.root, 'cache'); + fs.mkdirSync(cache); + const env = { + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`, + HOME: value.root, + TMPDIR: value.root, + npm_config_update_notifier: 'false', + }; + const seeded = spawnSync( + process.execPath, + [ + path.join(npmRoot, 'bin/npm-cli.js'), + 'cache', + 'add', + archive, + '--offline', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--userconfig=/dev/null', + `--globalconfig=${path.join(cache, 'release-disabled-global.npmrc')}`, + '--cache', + cache, + ], + { env, encoding: 'utf8', timeout: 15000 } + ); + expect(seeded.status, seeded.stderr).toBe(0); + const script = ` + import { createRequire } from 'node:module'; + import { installLifecycleObserver } from ${JSON.stringify(observer)}; + const require = createRequire(import.meta.url); + const [npmRoot, installRoot, cache] = process.argv.slice(1); + const state = installLifecycleObserver({ npmRoot, installRoot, nodePath: process.execPath }); + process.once('exit', code => console.log('OBSERVER_TEST_RESULT ' + JSON.stringify({ code, failed: state.failed(), events: state.events }))); + process.argv = [process.execPath, npmRoot + '/bin/npm-cli.js', 'ci', '--offline', '--include=dev', '--no-audit', '--no-fund', '--foreground-scripts', '--ignore-scripts=false', '--userconfig=/dev/null', '--globalconfig=' + cache + '/release-disabled-global.npmrc', '--script-shell=/bin/sh', '--cache', cache]; + require(npmRoot + '/bin/npm-cli.js'); + `; + const child = spawnSync( + process.execPath, + ['--input-type=module', '-e', script, npmRoot, value.install, cache], + { cwd: value.install, env, encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 } + ); + expect(child.status, child.stderr).toBe(0); + const result = child.stdout.split('\n').find((line) => line.startsWith('OBSERVER_TEST_RESULT ')); + expect(result, child.stderr).toBeTruthy(); + return JSON.parse(result.slice('OBSERVER_TEST_RESULT '.length)); +} +afterEach(() => { + for (const root of temporary.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('release npm lifecycle observer', () => { + it('rejects requests without a private full-length signing key', () => { + expect(() => parseRequest(Buffer.from('{}'))).toThrow(); + }); + it('binds closed requests to disjoint canonical roots and exact original identities', () => { + const first = fixture(); + const cache = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ts-cache-'))); + temporary.push(cache); + const request = { + schema_version: 1, + key: 'a'.repeat(64), + nonce: 'b'.repeat(64), + source_commit: 'c'.repeat(40), + repo_root: first.root, + cache, + guard_path: fs.realpathSync(process.execPath), + native_esbuild: { + lock_path: 'node_modules/@esbuild/linux-x64', + sha256: 'd'.repeat(64), + size: 4, + mode: 0o755, + }, + }; + expect(parseRequest(Buffer.from(JSON.stringify(request)))).toEqual(request); + for (const change of [ + { key: 'a' }, + { nonce: 'b' }, + { source_commit: 'HEAD' }, + { cache: first.install }, + { command: 'other' }, + ]) { + expect(() => parseRequest(Buffer.from(JSON.stringify({ ...request, ...change })))).toThrow(); + } + expect(() => + parseRequest( + Buffer.from( + JSON.stringify(request).replace( + '"schema_version":1', + '"schema_version":1,"schema_version":1' + ) + ) + ) + ).toThrow(); + }); + it('observes a genuine no-output hook start and successful completion', () => { + const value = fixture('core-js'); + const result = executeHook(value, ''); + expect(result.failed).toBe(false); + expect(result.events.map((event) => event.stage)).toEqual(['start', 'success']); + expect(result.events[1].changes).toEqual([]); + expect(result.events[0].script_sha256).toBe( + crypto.createHash('sha256').update(value.pkg.scripts.postinstall).digest('hex') + ); + }); + it('records actual changed owned files and pins node after package bin shadowing', () => { + const value = fixture(); + const result = executeHook( + value, + "require('node:fs').writeFileSync('observed.txt', process.execPath);", + { shadow: true } + ); + expect(result.failed).toBe(false); + expect(fs.readFileSync(path.join(value.owner, 'observed.txt'), 'utf8')).toBe(process.execPath); + expect(result.events[1].changes).toEqual([ + { + path: 'node_modules/esbuild/observed.txt', + before: null, + after: { + kind: 'file', + executable: false, + size: Buffer.byteLength(process.execPath), + sha256: crypto.createHash('sha256').update(process.execPath).digest('hex'), + }, + }, + ]); + }); + it('retains a failed event even when its caller swallows the optional failure', () => { + const result = executeHook(fixture(), 'process.exit(7);'); + expect(result.caught).toBe(true); + expect(result.failed).toBe(true); + expect(result.events.map((event) => event.stage)).toEqual(['start', 'failure']); + expect(result.events.at(-1).exit_code).toBe(7); + }); + it('runs real fresh offline npm ci including its absent root lifecycle calls', () => { + const result = executeFreshCi({ optional: false, failHook: false }); + expect(result.code).toBe(0); + expect(result.failed).toBe(false); + expect(result.events.map((event) => event.stage)).toEqual(['start', 'success']); + }); + it('detects an optional hook failure swallowed by actual npm ci and Arborist', () => { + const result = executeFreshCi({ optional: true, failHook: true }); + expect(result.code).toBe(0); + expect(result.failed).toBe(true); + expect(result.events.map((event) => event.stage)).toEqual(['start', 'failure']); + expect(result.events.at(-1).exit_code).toBe(7); + }); + it('does not pass the parent input pipe to the lifecycle child', () => { + const value = fixture(); + const result = executeHook( + value, + "require('node:fs').writeFileSync('stdin.txt', require('node:fs').readFileSync(0, 'utf8'));" + ); + expect(result.failed).toBe(false); + expect(fs.readFileSync(path.join(value.owner, 'stdin.txt'), 'utf8')).toBe(''); + }); + it('rejects writes outside the lifecycle owner and duplicate executions', () => { + const outside = executeHook( + fixture(), + "require('node:fs').writeFileSync('../../unexpected.txt', 'changed');" + ); + expect(outside.failed).toBe(true); + expect(outside.events.at(-1).stage).toBe('failure'); + const repeated = executeHook(fixture(), '', { second: true }); + expect(repeated.failed).toBe(true); + expect(repeated.events.map((event) => event.stage)).toEqual(['start', 'success']); + }); + it('rejects an unreviewed script before executing it', () => { + const value = fixture(); + value.pkg.scripts.postinstall = 'node unreviewed.js'; + fs.writeFileSync(path.join(value.owner, 'package.json'), JSON.stringify(value.pkg)); + const result = executeHook( + value, + "require('node:fs').writeFileSync('unwanted.txt', 'changed');" + ); + expect(result.failed).toBe(true); + expect(result.events).toEqual([]); + expect(fs.existsSync(path.join(value.owner, 'unwanted.txt'))).toBe(false); + }); + it('rejects hard links and escaping aliases during owned file measurement', () => { + const value = fixture(); + fs.writeFileSync(path.join(value.root, 'outside'), 'example'); + fs.linkSync(path.join(value.root, 'outside'), path.join(value.install, 'linked')); + expect(() => snapshotTree(value.install)).toThrow(); + fs.unlinkSync(path.join(value.install, 'linked')); + fs.symlinkSync('../outside', path.join(value.install, 'alias')); + expect(() => snapshotTree(value.install)).toThrow(); + }); + it('normalizes only the original-matched esbuild native hard-link pair after its real hook', () => { + const value = fixture(); + const lockPath = 'node_modules/@esbuild/linux-x64'; + const binary = path.join(value.install, lockPath, 'bin/esbuild'); + fs.mkdirSync(path.dirname(binary), { recursive: true }); + const bytes = Buffer.from('example native executable bytes'); + fs.writeFileSync(binary, bytes, { mode: 0o755 }); + const owned = path.join(value.owner, 'bin/esbuild'); + fs.mkdirSync(path.dirname(owned), { recursive: true }); + fs.writeFileSync(owned, 'example wrapper', { mode: 0o755 }); + const lock = JSON.parse(fs.readFileSync(path.join(value.install, 'package-lock.json'))); + lock.packages[lockPath] = { version: '0.27.3' }; + fs.writeFileSync(path.join(value.install, 'package-lock.json'), JSON.stringify(lock)); + const native = { + lock_path: lockPath, + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + size: bytes.length, + mode: 0o755, + }; + const result = executeHook( + value, + "const fs = require('node:fs'); fs.unlinkSync('bin/esbuild'); fs.linkSync('../@esbuild/linux-x64/bin/esbuild', 'bin/esbuild');", + { native } + ); + expect(result.failed).toBe(false); + expect(result.normalizations).toEqual([ + { + kind: 'esbuild-hardlink-copy-v1', + path: 'node_modules/esbuild/bin/esbuild', + native_path: `${lockPath}/bin/esbuild`, + sha256: native.sha256, + size: bytes.length, + mode: 0o755, + }, + ]); + expect(fs.lstatSync(binary).nlink).toBe(1); + expect(fs.lstatSync(owned).nlink).toBe(1); + expect(fs.lstatSync(binary).ino).not.toBe(fs.lstatSync(owned).ino); + expect(fs.readFileSync(binary)).toEqual(bytes); + expect(fs.readFileSync(owned)).toEqual(bytes); + expect(result.events.at(-1).changes.map((change) => change.path)).toEqual([ + 'node_modules/esbuild/bin/esbuild', + ]); + expect(() => + normalizeEsbuildHardlink({ + installRoot: value.install, + native: { ...native, sha256: '0'.repeat(64) }, + }) + ).toThrow(); + fs.unlinkSync(owned); + fs.linkSync(binary, owned); + fs.linkSync(binary, path.join(value.owner, 'extra-link')); + expect(() => normalizeEsbuildHardlink({ installRoot: value.install, native })).toThrow(); + }); + it('authenticates exact summary bytes with a key absent from the encoded payload', () => { + const key = crypto.randomBytes(32).toString('hex'); + const value = { schema_version: 1, nonce: 'b'.repeat(64), success: true, events: [] }; + const sealed = sealObservation(value, key); + const [prefix, encoded, signature] = sealed.trim().split(' '); + expect(prefix).toBe('TS_RELEASE_NPM_OBSERVATION_V1'); + const bytes = Buffer.from(encoded, 'base64'); + expect(JSON.parse(bytes)).toEqual(value); + expect(bytes.includes(Buffer.from(key))).toBe(false); + expect(signature).toBe( + crypto.createHmac('sha256', Buffer.from(key, 'hex')).update(bytes).digest('hex') + ); + expect(signature).not.toBe( + crypto + .createHmac('sha256', Buffer.from(key, 'hex')) + .update(Buffer.from(JSON.stringify({ ...value, success: false }))) + .digest('hex') + ); + }); +}); + +describe('parent-derived source context', () => { + it('discovers selected module identities without a Git store in the fresh checkout', async () => { + const { expectedModuleIds } = await import('../../../../scripts/release/collect-js.mjs'); + const value = fixture(); + const contextRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ts-context-'))); + temporary.push(contextRoot); + const context = path.join(contextRoot, 'source.json'); + const source = 'c'.repeat(40); + const record = { schema_version: 1, source_commit: source, module_ids: ['core', 'example'] }; + fs.writeFileSync(context, JSON.stringify(record)); + expect(expectedModuleIds(value.install, source, context)).toEqual(['core', 'example']); + for (const change of [ + { source_commit: 'd'.repeat(40) }, + { module_ids: ['example'] }, + { module_ids: ['example', 'core'] }, + { module_ids: ['core', 'core'] }, + { module_ids: ['core', '../example'] }, + { extra: true }, + ]) { + fs.writeFileSync(context, JSON.stringify({ ...record, ...change })); + expect(() => expectedModuleIds(value.install, source, context)).toThrow(); + } + const writable = path.join(value.install, 'source.json'); + fs.writeFileSync(writable, JSON.stringify(record)); + expect(() => expectedModuleIds(value.install, source, writable)).toThrow(/outside/); + }); +}); diff --git a/crates/trusted-server-js/lib/test/release-composition.test.mjs b/crates/trusted-server-js/lib/test/release-composition.test.mjs new file mode 100644 index 0000000000..db9eb589ee --- /dev/null +++ b/crates/trusted-server-js/lib/test/release-composition.test.mjs @@ -0,0 +1,1293 @@ +// @vitest-environment node +import crypto from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { build } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildAll } from '../build-all.mjs'; +import { moduleBuildOptions } from '../build-module-options.mjs'; + +const libDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(libDir, '../../..'); +const lockFile = path.join(libDir, 'package-lock.json'); +const archiveSource = JSON.parse(fs.readFileSync(lockFile, 'utf8')).packages['node_modules/dlv'] + .resolved; +const temporary = []; +const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); +function fixture() { + const root = fs.mkdtempSync(path.join(libDir, '.release-test-')); + temporary.push(root); + return root; +} +afterEach(() => { + vi.unstubAllEnvs(); + for (const root of temporary.splice(0)) { + if (fs.lstatSync(root, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(root); + else fs.rmSync(root, { recursive: true, force: true }); + } +}); +async function bundle(root, entry, composition, plugins = []) { + const outDir = path.join(root, composition ? 'recorded' : 'plain'); + const options = moduleBuildOptions({ name: 'fixture', entryPath: entry, outDir, composition }); + options.plugins = [...(options.plugins ?? []), ...plugins]; + await build(options); + return fs.readFileSync(path.join(outDir, 'tsjs-fixture.js')); +} +function recorder(onContributions, overrides = {}) { + return { repoRoot, lockFile, onContributions, ...overrides }; +} + +describe('release Rollup composition', () => { + it('reuses checked installed ownership for recorded module builds', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const owners = installedOrigins({ repoRoot, lockFile }); + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(entry, 'globalThis.exampleResult = 7;'); + let rows; + const recorded = await bundle( + root, + entry, + recorder( + (value) => { + rows = value; + }, + { + owners, + lockFile: path.join(root, 'unused-lock.json'), + } + ) + ); + expect(recorded).toEqual(await bundle(root, entry)); + expect(rows).toHaveLength(1); + }); + it('omits a rendered zero-length namespace module without changing bundle bytes', async () => { + const root = fixture(); + const empty = path.join(root, 'empty.ts'); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(empty, 'export {};'); + fs.writeFileSync(entry, "import * as empty from './empty'; globalThis.exampleResult = empty;"); + let rows; + let renderedLength; + const observe = { + name: 'example-rendered-module-observer', + generateBundle(_options, output) { + renderedLength = Object.values(output)[0].modules[empty].renderedLength; + }, + }; + const recorded = await bundle( + root, + entry, + recorder((value) => { + rows = value; + }), + [observe] + ); + expect(renderedLength).toBe(0); + expect(rows.some((row) => row.path?.endsWith('/empty.ts'))).toBe(false); + expect(recorded).toEqual(await bundle(root, entry)); + }); + it('records retained source and transitive npm helpers without changing final bytes', async () => { + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(path.join(root, 'unused.ts'), 'export const discarded = 42;'); + fs.writeFileSync(path.join(root, 'retained.ts'), 'export const retained = "example-retained";'); + fs.writeFileSync( + entry, + `import type { BidRequest } from 'prebid.js'; + import { discarded } from './unused'; + import { retained } from './retained'; + import dlv from 'dlv/dist/dlv.js'; + const request: BidRequest | undefined = undefined; + globalThis.exampleResult = [retained, dlv({ x: 7 }, 'x'), request];` + ); + let contributions; + const plain = await bundle(root, entry); + const recorded = await bundle( + root, + entry, + recorder((rows) => { + contributions = rows; + }) + ); + expect(recorded).toEqual(plain); + expect(contributions).toBeDefined(); + expect(contributions.some((row) => row.path?.endsWith('/retained.ts'))).toBe(true); + expect(contributions.some((row) => row.path?.endsWith('/unused.ts'))).toBe(false); + expect(contributions.some((row) => row.package_name === 'prebid.js')).toBe(false); + const npm = contributions.find((row) => row.package_name === 'dlv'); + expect(npm).toMatchObject({ + kind: 'npm', + lock_path: 'node_modules/dlv', + path: 'dist/dlv.js', + stage: 'rollup-rendered', + }); + expect(npm.sha256).toBe( + sha256(fs.readFileSync(path.join(libDir, 'node_modules/dlv/dist/dlv.js'))) + ); + expect(contributions.find((row) => row.id === '\0commonjsHelpers.js')).toMatchObject({ + kind: 'generated', + plugin: 'commonjs', + owner_lock_path: 'node_modules/vite', + }); + expect(contributions.every((row) => row.rendered_length > 0)).toBe(true); + }); + + it('preserves the actual core and Prebid bundle bytes', async () => { + for (const name of ['core', 'prebid']) { + const root = fixture(); + const entry = path.join( + libDir, + 'src', + name === 'core' ? 'core' : 'integrations/prebid', + 'index.ts' + ); + let rows; + expect( + await bundle( + root, + entry, + recorder((value) => { + rows = value; + }) + ) + ).toEqual(await bundle(root, entry)); + expect(rows).toBeDefined(); + expect(rows.some((row) => row.package_name === 'prebid.js')).toBe(false); + } + }); + + it('records original raw HTML and inline CSS hashes with their transformations', async () => { + const root = fixture(); + fs.writeFileSync(path.join(root, 'example.html'), '
example
'); + fs.writeFileSync(path.join(root, 'example.css'), '.example { color: red; }'); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync( + entry, + `import html from './example.html?raw'; import css from './example.css?inline'; globalThis.exampleResult = [html, css];` + ); + let rows; + const bytes = await bundle( + root, + entry, + recorder((value) => { + rows = value; + }) + ); + expect(bytes).toEqual(await bundle(root, entry)); + expect(rows.find((row) => row.path?.endsWith('/example.html'))).toMatchObject({ + transform: 'raw', + sha256: sha256(fs.readFileSync(path.join(root, 'example.html'))), + }); + expect(rows.find((row) => row.path?.endsWith('/example.css'))).toMatchObject({ + transform: 'inline', + }); + }); + + it('rejects an unrecognized virtual producer rather than assigning it to Vite', async () => { + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync( + entry, + `import value from 'virtual:example'; globalThis.exampleResult = value;` + ); + const plugin = { + name: 'example-unreviewed', + resolveId(id) { + if (id === 'virtual:example') return '\0example-unreviewed'; + }, + load(id) { + if (id === '\0example-unreviewed') return 'export default 42;'; + }, + }; + await expect( + bundle( + root, + entry, + recorder(() => {}), + [plugin] + ) + ).rejects.toThrow(/unknown virtual/i); + }); + + it.each(['version', 'integrity'])( + 'rejects contributing installed package %s ambiguity', + async (field) => { + const root = fixture(); + const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8')); + if (field === 'version') lock.packages['node_modules/dlv'].version = '0.0.0'; + else delete lock.packages['node_modules/dlv'].integrity; + const changedLock = path.join(root, 'package-lock.json'); + fs.writeFileSync(changedLock, JSON.stringify(lock)); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync( + entry, + `import dlv from 'dlv/dist/dlv.js'; globalThis.exampleResult = dlv({ x: 7 }, 'x');` + ); + await expect( + bundle( + root, + entry, + recorder(() => {}, { lockFile: changedLock, installRoot: libDir }) + ) + ).rejects.toThrow(/version|integrity/i); + } + ); +}); + +describe('release collector boundaries', () => { + it('accepts only absent or exact 1 for the optional full collector test control', async () => { + for (const value of ['', '0', 'true']) { + vi.stubEnv('TRUSTED_SERVER__RELEASE_JS_TESTS', value); + vi.resetModules(); + await expect(import('../vitest.config.ts')).rejects.toThrow(/RELEASE_JS_TESTS.*exactly 1/); + } + vi.stubEnv('TRUSTED_SERVER__RELEASE_JS_TESTS', undefined); + vi.resetModules(); + await expect(import('../vitest.config.ts')).resolves.toHaveProperty('default.test'); + }); + it('refuses enabling the full collector against the unsupported observer pin', async () => { + vi.stubEnv('TRUSTED_SERVER__RELEASE_JS_TESTS', '1'); + vi.resetModules(); + await expect(import('../vitest.config.ts')).rejects.toThrow( + /Controlled observer pins esbuild 0\.27\.3; current lock is .*compatibility doc/ + ); + }); + it('runs symlinked build and collector CLIs while imported entry points stay inert', async () => { + const root = fixture(); + const direct = path.join(root, 'direct'); + const linked = path.join(root, 'linked'); + const buildScript = path.join(libDir, 'build-all.mjs'); + const buildLink = path.join(root, 'build-link.mjs'); + fs.symlinkSync(buildScript, buildLink); + const env = { ...process.env, TSJS_SKIP_BUILD: '1' }; + delete env.TRUSTED_SERVER__RELEASE_BUILD; + const run = (script, output) => + spawnSync(process.execPath, [script, '--out-dir', output], { env, encoding: 'utf8' }); + expect(run(buildScript, direct).status).toBe(0); + expect(run(buildLink, linked).status).toBe(0); + const files = fs.readdirSync(direct).sort(); + expect(files).toContain('tsjs-core.js'); + expect(files.length).toBeGreaterThan(1); + expect(fs.readdirSync(linked).sort()).toEqual(files); + for (const file of files.filter((name) => name.startsWith('tsjs-'))) + expect(fs.readFileSync(path.join(linked, file))).toEqual( + fs.readFileSync(path.join(direct, file)) + ); + const collector = path.join(repoRoot, 'scripts/release/collect-js.mjs'); + const collectorLink = path.join(root, 'collector-link.mjs'); + fs.symlinkSync(collector, collectorLink); + const invalid = spawnSync(process.execPath, [collectorLink], { env, encoding: 'utf8' }); + expect(invalid.status).not.toBe(0); + expect(invalid.stderr).toMatch(/Usage: collect-js/); + const imported = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import ${JSON.stringify(pathToFileURL(buildScript).href)}; import ${JSON.stringify(pathToFileURL(collector).href)};`, + ], + { env, encoding: 'utf8' } + ); + expect(imported.status).toBe(0); + expect(imported.stdout).toBe(''); + const guardedLink = path.join(libDir, `.release-test-entrypoint-${crypto.randomUUID()}.mjs`); + fs.symlinkSync(buildScript, guardedLink); + temporary.push(guardedLink); + const rejectedOutput = path.join(root, 'rejected-entrypoint'); + const rejected = spawnSync( + process.execPath, + ['--preserve-symlinks-main', guardedLink, '--out-dir', rejectedOutput], + { env, encoding: 'utf8' } + ); + expect(rejected.status).not.toBe(0); + expect(rejected.stderr).toMatch(/build-all entrypoint identity check failed/); + expect(fs.existsSync(rejectedOutput)).toBe(false); + }, 30_000); + it('reaches bootstrap refusal from absolute, relative and own-directory shell invocation', () => { + const directory = path.join(repoRoot, 'scripts/release'); + for (const file of ['build.sh', 'build-js.sh']) { + for (const [cwd, script] of [ + [repoRoot, path.join(directory, file)], + [repoRoot, `scripts/release/${file}`], + [directory, file], + ]) { + const result = spawnSync('/bin/sh', [script], { cwd, encoding: 'utf8' }); + expect(result.status).toBe(1); + expect(result.stderr.trim()).toBe( + 'Release bootstrap is pending the reviewed Rust tooling replacement' + ); + } + } + }); + it.each([ + '{"a":1,"a":2}', + '{"a":{"b":1,"b":2}}', + '[1e999]', + '['.repeat(66) + '0' + ']'.repeat(66), + ])('rejects ambiguous JSON %s', async (text) => { + const { strictJson } = await import('../release-composition.mjs'); + expect(() => strictJson(Buffer.from(text))).toThrow(); + }); + it('rejects invalid UTF-8 and oversized preparation JSON', async () => { + const { strictJson } = await import('../release-composition.mjs'); + expect(() => strictJson(Buffer.from([0xff]))).toThrow(); + expect(() => strictJson(Buffer.alloc(1024 * 1024 + 1, 32))).toThrow(); + }); + it('validates the existing release controls, including any skip presence', async () => { + const { releaseBuild } = await import('../release-composition.mjs'); + for (const control of ['', '0', 'true']) + expect(() => releaseBuild({ TRUSTED_SERVER__RELEASE_BUILD: control })).toThrow(); + for (const skip of ['', '0', '1']) { + expect(releaseBuild({ TSJS_SKIP_BUILD: skip })).toBe(false); + expect(() => + releaseBuild({ + TSJS_SKIP_BUILD: skip, + TRUSTED_SERVER__RELEASE_BUILD: '1', + TRUSTED_SERVER__GIT_VERSION: 'a'.repeat(40), + }) + ).toThrow(/skip/i); + } + expect(() => releaseBuild({ TRUSTED_SERVER__RELEASE_BUILD: '1' })).toThrow(); + expect( + releaseBuild({ + TRUSTED_SERVER__RELEASE_BUILD: '1', + TRUSTED_SERVER__GIT_VERSION: 'a'.repeat(40), + }) + ).toBe(true); + }); + it('suppresses all four ambient env files in release build and Vitest options', async () => { + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(entry, 'globalThis.exampleResult = import.meta.env.VITE_EXAMPLE;'); + vi.stubEnv('TRUSTED_SERVER__RELEASE_BUILD', '1'); + vi.stubEnv('TRUSTED_SERVER__GIT_VERSION', 'a'.repeat(40)); + vi.stubEnv('TSJS_SKIP_BUILD', undefined); + try { + const options = moduleBuildOptions({ + name: 'fixture', + entryPath: entry, + outDir: path.join(root, 'out'), + }); + expect(options.envDir).toBe(false); + expect(options.mode).toBe('production'); + options.root = root; + await build(options); + const expected = fs.readFileSync(path.join(root, 'out/tsjs-fixture.js')); + for (const file of ['.env', '.env.local', '.env.production', '.env.production.local']) + fs.writeFileSync(path.join(root, file), 'VITE_EXAMPLE=fictional-environment-value'); + await build(options); + expect(fs.readFileSync(path.join(root, 'out/tsjs-fixture.js'))).toEqual(expected); + vi.resetModules(); + const config = (await import('../vitest.config.ts')).default; + expect(config.envDir).toBe(false); + expect(config.mode).toBe('production'); + expect(config.server.host).toBe('127.0.0.1'); + const nativeImports = config.plugins.find( + (plugin) => plugin.name === 'release-test-node-builtins' + ); + expect(nativeImports.resolveId('node:fs')).toEqual({ id: 'node:fs', external: true }); + expect(nativeImports.resolveId('dlv')).toBeUndefined(); + vi.stubEnv('TRUSTED_SERVER__RELEASE_BUILD', undefined); + vi.resetModules(); + const ordinary = (await import('../vitest.config.ts')).default; + expect(ordinary.server).toBeUndefined(); + expect(ordinary.plugins).toBeUndefined(); + expect(ordinary.envDir).toBeUndefined(); + expect(ordinary.mode).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + }); + it('rejects source symlink escape and unknown transform queries', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const source = path.join(root, 'source'); + const external = path.join(root, 'external'); + fs.mkdirSync(source); + fs.mkdirSync(external); + const sourceLock = path.join(source, 'package-lock.json'); + fs.writeFileSync(sourceLock, JSON.stringify({ lockfileVersion: 3, packages: { '': {} } })); + fs.writeFileSync(path.join(external, 'outside.ts'), 'export const example = 7;'); + const link = path.join(source, 'alias.ts'); + fs.symlinkSync(path.join(external, 'outside.ts'), link); + expect(path.relative(fs.realpathSync(source), fs.realpathSync(link))).toMatch(/^\.\.[/\\]/); + const origins = installedOrigins({ repoRoot: source, lockFile: sourceLock }); + expect(() => origins.origin(link)).toThrow(/escape/i); + expect(() => origins.origin(path.join(source, 'entry.ts?unknown'))).toThrow(/query/i); + }); + it('requires checked preparation records rather than inventing lifecycle observations', async () => { + const collector = await import(path.join(repoRoot, 'scripts/release/collect-js.mjs')); + await expect( + collector.collectJs({ + repoRoot, + outDir: path.join(fixture(), 'bundles'), + inventoryPath: path.join(fixture(), 'inventory.json'), + }) + ).rejects.toThrow(/preparation/i); + }); +}); + +describe('prepared evidence and installed trees', () => { + it('hashes normalized executable modes and excludes separately owned nested packages', async () => { + const { installedTreeHash } = await import('../release-composition.mjs'); + const root = fixture(); + fs.writeFileSync(path.join(root, 'example.js'), 'example bytes'); + const nested = path.join(root, 'node_modules/example-dependency'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, 'example.js'), 'nested bytes'); + const exclusions = new Set([nested]); + const first = installedTreeHash(root, exclusions); + fs.writeFileSync(path.join(nested, 'example.js'), 'changed nested bytes'); + expect(installedTreeHash(root, exclusions)).toBe(first); + fs.chmodSync(path.join(root, 'example.js'), 0o755); + expect(installedTreeHash(root, exclusions)).not.toBe(first); + }); + it('checks preparation distribution file and lifecycle output hashes', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + const root = fixture(); + const file = path.join(root, 'tool'); + fs.writeFileSync(file, 'fictional distribution bytes'); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ scripts: { install: 'example install command' } }) + ); + const output = path.join(packageRoot, 'generated.js'); + fs.writeFileSync(output, 'fictional lifecycle output'); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const material = { + kind: 'tool-distribution', + id: 'tool:example', + name: 'example', + version: '1.0.0', + sha256: sha256(fs.readFileSync(file)), + }; + const prepared = { + schema_version: 1, + tools: [{ name: 'example', version: '1.0.0', role: 'bundler', material_id: material.id }], + distributions: [{ material, path: file }], + packages: [npm], + lifecycle_events: [ + { + kind: 'lifecycle-event', + id: 'event:example', + package_material_id: npm.id, + event: 'install', + script_sha256: sha256('example install command'), + }, + ], + lifecycle_outputs: [ + { + kind: 'lifecycle-output', + id: 'lifecycle:example', + package_material_id: npm.id, + event: 'install', + script_sha256: sha256('example install command'), + path: 'node_modules/example/generated.js', + size: fs.statSync(output).size, + sha256: sha256(fs.readFileSync(output)), + }, + ], + }; + const checked = validatePreparation(prepared, { installRoot: root, packages: [npm] }); + expect(checked.materials).toContainEqual(material); + expect(checked.materials.some((record) => record.kind === 'lifecycle-output')).toBe(true); + const broken = structuredClone(prepared); + broken.distributions[0].material.sha256 = 'b'.repeat(64); + expect(() => validatePreparation(broken, { installRoot: root, packages: [npm] })).toThrow( + /digest/i + ); + const duplicate = structuredClone(prepared); + duplicate.packages.push(npm); + expect(() => validatePreparation(duplicate, { installRoot: root, packages: [npm] })).toThrow( + /duplicate/i + ); + }); +}); + +describe('complete inventory producer', () => { + it('binds measured bundler native files separately from npm archive integrity', async () => { + const { actualToolFiles } = await import('../release-composition.mjs'); + const tools = actualToolFiles({ installRoot: libDir }); + expect(tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining([ + 'node', + 'npm', + 'vite', + 'rollup', + 'esbuild', + 'vitest', + `esbuild:${process.platform}-${process.arch}`, + `rollup:${process.platform}-${process.arch}`, + ]) + ); + for (const tool of tools) { + expect(tool.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(tool.path).toBeTruthy(); + expect(tool.version).toBeTruthy(); + } + }); + + it('rejects lifecycle records that are not bound to the package install script', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + const root = fixture(); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ + name: 'example', + version: '1.0.0', + scripts: { install: 'example observed command' }, + }) + ); + fs.writeFileSync(path.join(packageRoot, 'generated.js'), 'example output'); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const preparation = { + schema_version: 1, + tools: [], + distributions: [], + packages: [npm], + lifecycle_events: [ + { + kind: 'lifecycle-event', + id: 'event:example', + package_material_id: npm.id, + event: 'install', + script_sha256: sha256('example install command'), + }, + ], + lifecycle_outputs: [ + { + kind: 'lifecycle-output', + id: 'lifecycle:example', + package_material_id: npm.id, + event: 'install', + script_sha256: sha256('unrelated command'), + path: 'node_modules/example/generated.js', + size: 14, + sha256: sha256('example output'), + }, + ], + }; + expect(() => validatePreparation(preparation, { installRoot: root, packages: [npm] })).toThrow( + /script/i + ); + }); + + it('joins a nested transitive package by its exact installation location', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const installed = path.join(root, 'node_modules/example-parent/node_modules/example-child'); + fs.mkdirSync(installed, { recursive: true }); + fs.writeFileSync( + path.join(installed, 'package.json'), + JSON.stringify({ name: 'example-child', version: '1.2.3' }) + ); + fs.writeFileSync(path.join(installed, 'index.js'), 'export default 7;'); + const key = 'node_modules/example-parent/node_modules/example-child'; + const lock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + lock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + [key]: { + version: '1.2.3', + resolved: archiveSource, + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + }, + }, + }) + ); + const alias = `${root}-alias`; + fs.symlinkSync(root, alias); + temporary.push(alias); + expect( + installedOrigins({ repoRoot, lockFile: lock, installRoot: alias }).origin( + path.join(alias, key, 'index.js') + ) + ).toMatchObject({ lock_path: key, package_name: 'example-child' }); + expect( + installedOrigins({ repoRoot, lockFile: lock }).origin(path.join(installed, 'index.js')) + ).toMatchObject({ lock_path: key, package_name: 'example-child', path: 'index.js' }); + }); + + it.skip('disabled: controlled observer pins esbuild 0.27.3; current lock is 0.28.2 — see compat doc; writes deterministic closed inventory records after final Vite/esbuild output', async () => { + const { actualToolFiles, expectedLifecycleEvents, installedOrigins, installedMaterials } = + await import('../release-composition.mjs'); + const { collectJs } = await import(path.join(repoRoot, 'scripts/release/collect-js.mjs')); + const root = fixture(); + const packages = installedMaterials(installedOrigins({ repoRoot, lockFile })); + const measured = actualToolFiles({ installRoot: libDir }); + const distributions = measured.map(({ name, version, sha256: hash, path: file }) => ({ + material: { kind: 'tool-distribution', id: `tool:${name}`, name, version, sha256: hash }, + path: file, + })); + const tools = measured.map(({ name, version, role }) => ({ + name, + version, + role, + material_id: `tool:${name}`, + })); + const preparationPath = path.join(root, 'preparation.json'); + fs.writeFileSync( + preparationPath, + JSON.stringify({ + schema_version: 1, + packages, + distributions, + tools, + // Synthetic observations exercise joins; no preparation actually runs in this fixture. + lifecycle_events: expectedLifecycleEvents({ installRoot: libDir, packages }), + lifecycle_outputs: [], + }) + ); + // This is the fixture's source identity, not release approval. + const sourceContextPath = process.env.TRUSTED_SERVER__RELEASE_JS_SOURCE_CONTEXT; + const sourceCommit = sourceContextPath + ? JSON.parse(fs.readFileSync(sourceContextPath, 'utf8')).source_commit + : execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + vi.stubEnv('TRUSTED_SERVER__RELEASE_BUILD', '1'); + vi.stubEnv('TRUSTED_SERVER__GIT_VERSION', sourceCommit); + vi.stubEnv('TSJS_SKIP_BUILD', undefined); + try { + const inventoryPath = path.join(root, 'inventory.json'); + const outDir = path.join(root, 'bundles'); + const inventory = await collectJs({ + repoRoot, + outDir, + inventoryPath, + preparationPath, + sourceContextPath, + }); + expect(Object.keys(inventory).sort()).toEqual([ + 'materials', + 'modules', + 'schema_version', + 'source_commit', + 'tools', + ]); + expect(inventory.modules.map((row) => row.id)).toEqual( + [...inventory.modules.map((row) => row.id)].sort() + ); + const plainDirectory = path.join(root, 'plain-bundles'); + await buildAll({ outDir: plainDirectory }); + for (const module of inventory.modules) { + const bytes = fs.readFileSync(path.join(outDir, module.filename)); + expect(bytes).toEqual(fs.readFileSync(path.join(plainDirectory, module.filename))); + expect(module.size).toBe(bytes.length); + expect(module.sha256).toBe(sha256(bytes)); + expect(Object.keys(module).sort()).toEqual([ + 'contributions', + 'filename', + 'id', + 'sha256', + 'size', + ]); + } + expect(inventory.materials.some((row) => row.kind === 'lifecycle-output')).toBe(false); + expect(inventory.materials.some((row) => row.kind === 'lifecycle-event')).toBe(true); + for (const source of [ + 'crates/trusted-server-js/lib/tsconfig.json', + 'scripts/release/bootstrap.sh', + ]) { + expect(inventory.materials.find((row) => row.id === `source:${source}`)).toMatchObject({ + kind: 'source', + path: source, + sha256: sha256(fs.readFileSync(path.join(repoRoot, source))), + }); + } + expect(fs.readFileSync(inventoryPath, 'utf8')).toBe(`${JSON.stringify(inventory)}\n`); + const alias = path.join(root, 'bundle-alias'); + fs.symlinkSync(outDir, alias); + await expect( + collectJs({ + repoRoot, + outDir: alias, + inventoryPath: path.join(outDir, 'inside.json'), + preparationPath, + sourceContextPath, + }) + ).rejects.toThrow(/outside.*bundle/i); + await expect( + collectJs({ + repoRoot: fixture(), + outDir, + inventoryPath: path.join(root, 'wrong-checkout.json'), + preparationPath, + sourceContextPath, + }) + ).rejects.toThrow(/collector.*checkout/i); + const originalPreparation = fs.readFileSync(preparationPath); + const colliding = JSON.parse(originalPreparation); + const collision = 'source:crates/trusted-server-js/lib/package.json'; + colliding.distributions.find((row) => row.material.name === 'node').material.id = collision; + colliding.tools.find((row) => row.name === 'node').material_id = collision; + fs.writeFileSync(preparationPath, JSON.stringify(colliding)); + await expect( + collectJs({ + repoRoot, + outDir, + inventoryPath: path.join(root, 'collision.json'), + preparationPath, + sourceContextPath, + }) + ).rejects.toThrow(/duplicate.*material|material.*identity/i); + expect(fs.existsSync(path.join(root, 'collision.json'))).toBe(false); + fs.writeFileSync(preparationPath, originalPreparation); + const bad = JSON.parse(fs.readFileSync(preparationPath, 'utf8')); + bad.distributions.find((row) => row.material.name === 'vite').path = path.join( + root, + 'fictional-tool' + ); + const actualVite = measured.find((row) => row.name === 'vite'); + fs.copyFileSync(actualVite.path, path.join(root, 'fictional-tool')); + expect(sha256(fs.readFileSync(path.join(root, 'fictional-tool')))).toBe(actualVite.sha256); + fs.writeFileSync(preparationPath, JSON.stringify(bad)); + await expect( + collectJs({ + repoRoot, + outDir, + inventoryPath: path.join(root, 'bad.json'), + preparationPath, + sourceContextPath, + }) + ).rejects.toThrow(/actual.*tool.*identity/i); + } finally { + vi.unstubAllEnvs(); + } + }, 120_000); +}); + +describe('strict parser parity', () => { + it.each([ + Buffer.from('\ufeff{}'), + Buffer.from('"\\ud800"'), + Buffer.from('{"\\ud800":0}'), + Buffer.from('9007199254740993'), + Buffer.from('['.repeat(65) + ']'.repeat(65)), + ])('rejects ambiguous Unicode, integer or container input %#', async (bytes) => { + const { strictJson } = await import('../release-composition.mjs'); + expect(() => strictJson(bytes)).toThrow(); + }); + it('accepts exactly 64 containers and paired Unicode', async () => { + const { strictJson } = await import('../release-composition.mjs'); + expect(() => strictJson(Buffer.from('['.repeat(64) + ']'.repeat(64)))).not.toThrow(); + expect(strictJson(Buffer.from('"\\ud83d\\ude00"'))).toBe('😀'); + }); +}); + +describe('observed lifecycle events', () => { + it('records an executed lifecycle event even when it produced no output file', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + const root = fixture(); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ scripts: { postinstall: 'example observed command' } }) + ); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const event = { + kind: 'lifecycle-event', + id: 'lifecycle:example:postinstall', + package_material_id: npm.id, + event: 'postinstall', + script_sha256: sha256('example observed command'), + }; + const preparation = { + schema_version: 1, + tools: [], + distributions: [], + packages: [npm], + lifecycle_events: [event], + lifecycle_outputs: [], + }; + const checked = validatePreparation(preparation, { installRoot: root, packages: [npm] }); + expect(checked.materials).toContainEqual(event); + const changed = structuredClone(preparation); + changed.lifecycle_events[0].script_sha256 = 'b'.repeat(64); + expect(() => validatePreparation(changed, { installRoot: root, packages: [npm] })).toThrow( + /script/i + ); + }); +}); + +describe('observer output boundary', () => { + it('rejects an ES chunk even when its filename matches the IIFE contract', async () => { + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(entry, 'globalThis.exampleResult = 7;'); + const options = moduleBuildOptions({ + name: 'fixture', + entryPath: entry, + outDir: path.join(root, 'out'), + composition: recorder(() => {}), + }); + options.build.rollupOptions.output.format = 'es'; + await expect(build(options)).rejects.toThrow(/IIFE/i); + }); +}); + +describe('complete preparation lifecycle coverage', () => { + it('rejects an omitted installed package even when it has no lifecycle hooks', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + const root = fixture(); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ name: 'example', version: '1.0.0' }) + ); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const prepared = { + schema_version: 1, + tools: [], + distributions: [], + packages: [], + lifecycle_events: [], + lifecycle_outputs: [], + }; + expect(() => validatePreparation(prepared, { installRoot: root, packages: [npm] })).toThrow( + 'Incomplete prepared package set' + ); + }); + it('rejects a missing observation for an installed registry postinstall hook', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + const root = fixture(); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ scripts: { postinstall: 'example observed command' } }) + ); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const preparation = { + schema_version: 1, + tools: [], + distributions: [], + packages: [npm], + lifecycle_events: [], + lifecycle_outputs: [], + }; + expect(() => validatePreparation(preparation, { installRoot: root, packages: [npm] })).toThrow( + /missing.*lifecycle|incomplete.*lifecycle/i + ); + }); +}); + +describe('nested package contribution fixture', () => { + it('rejects another node_modules tree inside the repository but outside the install root', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const outside = path.join(root, 'node_modules/example'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync( + path.join(outside, 'package.json'), + JSON.stringify({ name: 'example', version: '1.0.0' }) + ); + fs.writeFileSync(path.join(outside, 'index.js'), 'export default 7;'); + const installRoot = path.join(root, 'install'); + fs.mkdirSync(installRoot); + const fixtureLock = path.join(installRoot, 'package-lock.json'); + fs.writeFileSync(fixtureLock, JSON.stringify({ lockfileVersion: 3, packages: {} })); + const origins = installedOrigins({ repoRoot, lockFile: fixtureLock }); + expect(() => origins.origin(path.join(outside, 'index.js'))).toThrow( + /unmapped installed ownership/i + ); + }); + it('assigns a nested type-only format manifest to its archive package', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const key = 'node_modules/@example/parent'; + const directory = path.join(root, key); + const format = path.join(directory, 'dist/esm'); + fs.mkdirSync(format, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ name: '@example/parent', version: '1.0.0' }) + ); + fs.writeFileSync(path.join(format, 'package.json'), JSON.stringify({ type: 'module' })); + fs.writeFileSync(path.join(format, 'index.js'), 'export default 7;'); + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + fixtureLock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + [key]: { + version: '1.0.0', + resolved: archiveSource, + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + }, + }, + }) + ); + const origins = installedOrigins({ repoRoot, lockFile: fixtureLock }); + expect(origins.origin(path.join(format, 'index.js'))).toMatchObject({ + lock_path: key, + path: 'dist/esm/index.js', + }); + const hostile = path.join(directory, 'node_modules/example-unmapped'); + fs.mkdirSync(hostile, { recursive: true }); + fs.writeFileSync( + path.join(hostile, 'package.json'), + JSON.stringify({ name: 'example-unmapped', version: '1.0.0' }) + ); + fs.writeFileSync(path.join(hostile, 'index.js'), 'export default 42;'); + expect(() => origins.origin(path.join(hostile, 'index.js'))).toThrow(/unmapped/i); + }); + it('preserves final bytes while recording the indirect nested installation identity', async () => { + const root = fixture(); + const parentKey = 'node_modules/example-parent'; + const childKey = `${parentKey}/node_modules/example-child`; + for (const [key, name, code] of [ + [parentKey, 'example-parent', "import value from 'example-child'; export default value;"], + [childKey, 'example-child', 'export default 7;'], + ]) { + const directory = path.join(root, key); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ name, version: '1.0.0', type: 'module', main: 'index.js' }) + ); + fs.writeFileSync(path.join(directory, 'index.js'), code); + } + const integrity = `sha512-${Buffer.alloc(64).toString('base64')}`; + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + fixtureLock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + [parentKey]: { version: '1.0.0', resolved: archiveSource, integrity }, + [childKey]: { version: '1.0.0', resolved: archiveSource, integrity }, + }, + }) + ); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync( + entry, + "import value from 'example-parent'; globalThis.exampleResult = value;" + ); + let rows; + const recorded = await bundle( + root, + entry, + recorder( + (value) => { + rows = value; + }, + { lockFile: fixtureLock } + ) + ); + expect(recorded).toEqual(await bundle(root, entry)); + expect(rows.find((row) => row.package_name === 'example-child')).toMatchObject({ + lock_path: childKey, + path: 'index.js', + }); + }); + it('rejects installed package aliases before contribution recording', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const real = path.join(root, 'node_modules/example'); + fs.mkdirSync(real, { recursive: true }); + fs.writeFileSync( + path.join(real, 'package.json'), + JSON.stringify({ name: 'example', version: '1.0.0' }) + ); + fs.symlinkSync(real, path.join(root, 'node_modules/example-alias')); + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + fixtureLock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/example': { version: '1.0.0' }, + 'node_modules/example-alias': { version: '1.0.0' }, + }, + }) + ); + expect(() => installedOrigins({ repoRoot, lockFile: fixtureLock })).toThrow(/alias|escape/i); + }); +}); + +describe('approved source module discovery', () => { + it.runIf(process.env.TRUSTED_SERVER__RELEASE_JS_TESTS === '1')( + 'uses the selected commit despite index removal or an invented staged integration', + async () => { + const { expectedModuleIds } = await import( + path.join(repoRoot, 'scripts/release/collect-js.mjs') + ); + const root = fixture(); + const git = (args) => + execFileSync( + 'git', + [ + '-c', + 'commit.gpgsign=false', + '-c', + 'user.name=Example', + '-c', + 'user.email=example@example.com', + ...args, + ], + { + cwd: root, + encoding: 'utf8', + env: { + PATH: process.env.PATH, + TMPDIR: os.tmpdir(), + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_NO_REPLACE_OBJECTS: '1', + }, + } + ); + git(['init', '-q']); + for (const file of [ + 'crates/trusted-server-js/lib/src/core/index.ts', + 'crates/trusted-server-js/lib/src/integrations/example/index.ts', + ]) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), 'export const example = 7;'); + } + git(['add', '.']); + git(['commit', '-qm', 'Record example source']); + const source = git(['rev-parse', 'HEAD']).trim(); + git([ + 'rm', + '--cached', + '-q', + 'crates/trusted-server-js/lib/src/integrations/example/index.ts', + ]); + const invented = path.join( + root, + 'crates/trusted-server-js/lib/src/integrations/invented/index.ts' + ); + fs.mkdirSync(path.dirname(invented), { recursive: true }); + fs.writeFileSync(invented, 'export const example = 42;'); + git(['add', invented]); + expect(expectedModuleIds(root, source)).toEqual(['core', 'example']); + expect(() => expectedModuleIds(root, 'a'.repeat(40))).toThrow(); + } + ); +}); + +describe('locked archive source policy', () => { + it.each(['link', 'file', 'git', 'missing'])( + 'rejects unsupported contributing lock source %s', + async (variant) => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8')); + const record = lock.packages['node_modules/dlv']; + if (variant === 'link') record.link = true; + if (variant === 'file') record.resolved = 'file:example.tgz'; + if (variant === 'git') record.resolved = 'git+https://example.com/example.git'; + if (variant === 'missing') delete record.resolved; + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync(fixtureLock, JSON.stringify(lock)); + const origins = installedOrigins({ repoRoot, lockFile: fixtureLock, installRoot: libDir }); + expect(() => origins.packageIdentity('node_modules/dlv')).toThrow( + /lock.*source|archive.*source/i + ); + } + ); +}); + +describe('bounded regular-file readers', () => { + it('rejects a FIFO without waiting for a writer', () => { + const root = fixture(); + const fifo = path.join(root, 'example.fifo'); + execFileSync('mkfifo', [fifo]); + const helper = pathToFileURL(path.join(libDir, 'release-composition.mjs')).href; + const code = `import { readBounded } from ${JSON.stringify(helper)}; try { readBounded(process.argv[1]); process.exit(2); } catch(error) { if (/regular file/.test(error.message)) process.exit(0); throw error; }`; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', code, fifo], { + timeout: 2000, + encoding: 'utf8', + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + }); +}); + +describe('lifecycle output package ownership', () => { + it.each(['nested-child', 'symlink-child'])( + 'rejects a parent event claiming a %s package output', + async (variant) => { + const { installedMaterials, installedOrigins, validatePreparation } = + await import('../release-composition.mjs'); + const root = fixture(); + const parentKey = 'node_modules/example-parent'; + const childKey = + variant === 'nested-child' + ? `${parentKey}/node_modules/example-child` + : 'node_modules/example-child'; + for (const [key, name] of [ + [parentKey, 'example-parent'], + [childKey, 'example-child'], + ]) { + const directory = path.join(root, key); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ + name, + version: '1.0.0', + ...(key === parentKey ? { scripts: { install: 'example install command' } } : {}), + }) + ); + } + const childOutput = path.join(root, childKey, 'output.js'); + fs.writeFileSync(childOutput, 'fictional child output'); + const integrity = `sha512-${Buffer.alloc(64).toString('base64')}`; + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + fixtureLock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + [parentKey]: { version: '1.0.0', resolved: archiveSource, integrity }, + [childKey]: { version: '1.0.0', resolved: archiveSource, integrity }, + }, + }) + ); + const packages = installedMaterials(installedOrigins({ repoRoot, lockFile: fixtureLock })); + const scriptHash = sha256('example install command'); + const event = { + kind: 'lifecycle-event', + id: 'event:parent', + package_material_id: `npm:${parentKey}`, + event: 'install', + script_sha256: scriptHash, + }; + let outputPath = `${childKey}/output.js`; + if (variant === 'symlink-child') { + // Exercise output validation independently of the prior tree measurement. + fs.symlinkSync(path.join(root, childKey), path.join(root, parentKey, 'linked')); + outputPath = `${parentKey}/linked/output.js`; + } + const prepared = { + schema_version: 1, + tools: [], + distributions: [], + packages, + lifecycle_events: [event], + lifecycle_outputs: [ + { + kind: 'lifecycle-output', + id: 'output:parent', + package_material_id: event.package_material_id, + event: event.event, + script_sha256: scriptHash, + path: outputPath, + size: fs.statSync(childOutput).size, + sha256: sha256(fs.readFileSync(childOutput)), + }, + ], + }; + expect(() => validatePreparation(prepared, { installRoot: root, packages })).toThrow( + /owner|escape/i + ); + } + ); +}); + +describe('installed tree mode measurement boundaries', () => { + it('hashes the executable mode observed on the opened descriptor', async () => { + const { installedTreeHash } = await import('../release-composition.mjs'); + const root = fixture(); + const file = path.join(root, 'example.js'); + fs.writeFileSync(file, 'example bytes'); + fs.chmodSync(file, 0o644); + const oldHash = installedTreeHash(root); + const open = fs.openSync; + let changed = false; + const boundary = vi.spyOn(fs, 'openSync').mockImplementation((...args) => { + if (args[0] === file && !changed) { + changed = true; + fs.chmodSync(file, 0o755); + } + return open(...args); + }); + let observed; + try { + observed = installedTreeHash(root); + } finally { + boundary.mockRestore(); + } + expect(changed).toBe(true); + expect(observed).not.toBe(oldHash); + expect(observed).toBe(installedTreeHash(root)); + }); + + it('rejects a mode change between the final descriptor and pathname checks', async () => { + const { installedTreeHash } = await import('../release-composition.mjs'); + const root = fixture(); + const file = path.join(root, 'example.js'); + fs.writeFileSync(file, 'example bytes'); + fs.chmodSync(file, 0o644); + const lstat = fs.lstatSync; + let observations = 0; + const boundary = vi.spyOn(fs, 'lstatSync').mockImplementation((...args) => { + if (args[0] === file && ++observations === 2) fs.chmodSync(file, 0o755); + return lstat(...args); + }); + try { + expect(() => installedTreeHash(root)).toThrow(/changed/i); + expect(observations).toBe(2); + } finally { + boundary.mockRestore(); + } + }); +}); diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index acb591cdc5..97115fc4d8 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -1,8 +1,47 @@ import path from 'node:path'; +import fs from 'node:fs'; import { defineConfig } from 'vitest/config'; +import { releaseBuild } from './release-composition.mjs'; + +if ( + Object.hasOwn(process.env, 'TRUSTED_SERVER__RELEASE_JS_TESTS') && + process.env.TRUSTED_SERVER__RELEASE_JS_TESTS !== '1' +) { + throw new Error('TRUSTED_SERVER__RELEASE_JS_TESTS must be exactly 1 when present'); +} + +// The frozen controlled observer is not an ordinary release dependency. +// Updating this gate and the observer requires the controlled-recipe review. +if (process.env.TRUSTED_SERVER__RELEASE_JS_TESTS === '1') { + const lock = JSON.parse(fs.readFileSync(new URL('./package-lock.json', import.meta.url), 'utf8')); + const version = lock.packages?.['node_modules/esbuild']?.version; + if (version !== '0.27.3') { + throw new Error( + `Controlled observer pins esbuild 0.27.3; current lock is ${version}; see Phase 2 compatibility doc` + ); + } +} + export default defineConfig({ + ...(releaseBuild() + ? { + envDir: false, + mode: 'production', + server: { host: '127.0.0.1' }, + // Preserve native imports that Vite otherwise replaces with browser stubs in production. + plugins: [ + { + name: 'release-test-node-builtins', + enforce: 'pre' as const, + resolveId(id: string) { + if (id.startsWith('node:')) return { id, external: true }; + }, + }, + ], + } + : {}), resolve: { alias: { // prebid.js doesn't expose src/adapterManager.js via its package diff --git a/crates/trusted-server-js/tests/release_prebuilt.rs b/crates/trusted-server-js/tests/release_prebuilt.rs new file mode 100644 index 0000000000..a9f153423c --- /dev/null +++ b/crates/trusted-server-js/tests/release_prebuilt.rs @@ -0,0 +1,1047 @@ +#![cfg(any(target_os = "linux", target_os = "macos"))] +#![allow( + dead_code, + reason = "actual build script is included for isolated process fixtures" +)] + +#[path = "../build.rs"] +mod build_script; + +use std::env; +use std::ffi::OsString; +use std::fs; +use std::os::unix::ffi::OsStringExt as _; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use tempfile::{TempDir, tempdir}; + +const SOURCE: &str = "0123456789abcdef0123456789abcdef01234567"; +const BYTES: &[u8] = b"console.log('example');"; +const TRACE_ASSETS: [(&str, &[u8]); 2] = [("v1.js", b"trace js"), ("v1.css", b"trace css")]; +/// Shell lines a fake `npm run build` uses to write the trace viewer into `$output`. +const TRACE_BUILD: &str = "/bin/mkdir -p \"$output/trace\"\nprintf 'trace js' > \"$output/trace/v1.js\"\nprintf 'trace css' > \"$output/trace/v1.css\"\n"; + +/// Seed self-consistent trace viewer inputs, which `build.rs` embeds on every build. +fn write_trace_inputs(lib: &Path) { + fs::write( + lib.join("trace-build-inputs.json"), + json!({ "files": [], "source_roots": [], "toolchain_roots": [] }).to_string(), + ) + .expect("should write trace build inputs"); + fs::write( + lib.join("package-lock.json"), + json!({ "packages": {} }).to_string(), + ) + .expect("should write package lock"); + fs::create_dir(lib.join("trace-assets")).expect("should create trace asset directory"); + let mut assets = Vec::new(); + for (file, bytes) in TRACE_ASSETS { + fs::write(lib.join("trace-assets").join(file), bytes).expect("should write trace asset"); + assets.push(json!({ + "path": format!("/_ts/trace/assets/{file}"), "file": file, + "sha256": hex::encode(Sha256::digest(bytes)), + })); + } + fs::write( + lib.join("trace-assets-manifest.json"), + json!({ + "schema_version": 1, + "source_sha256": build_script::trace_sources::trace_source_digest(lib), + "assets": assets, + }) + .to_string(), + ) + .expect("should write trace asset manifest"); +} + +struct Fixture { + root: TempDir, + inventory: Value, +} + +impl Fixture { + fn new() -> Self { + let root = tempdir().expect("should create fixture root"); + for name in ["lib/src/integrations", "bundles", "out", "bin"] { + fs::create_dir_all(root.path().join(name)).expect("should create fixture directory"); + } + fs::write(root.path().join("bundles/tsjs-core.js"), BYTES) + .expect("should write core bundle"); + write_trace_inputs(&root.path().join("lib")); + let npm = root.path().join("bin/npm"); + fs::write( + &npm, + "#!/bin/sh\n: > \"$CARGO_MANIFEST_DIR/npm-called\"\nexit 97\n", + ) + .expect("should write failing npm"); + fs::set_permissions(npm, fs::Permissions::from_mode(0o755)) + .expect("should make npm executable"); + Self { + root, + inventory: json!({ + "schema_version": 1, "source_commit": SOURCE, + "modules": [{ "id": "core", "filename": "tsjs-core.js", "size": BYTES.len(), + "sha256": hex::encode(Sha256::digest(BYTES)), "contributions": [] }], + "tools": [], "materials": [] + }), + } + } + + fn path(&self, name: &str) -> PathBuf { + self.root.path().join(name) + } + + fn command(&self) -> Command { + let mut command = Command::new(env::current_exe().expect("should locate test executable")); + command + .args(["--exact", "fixture_entry", "--ignored", "--nocapture"]) + .env_clear() + .env("CARGO_MANIFEST_DIR", self.root.path()) + .env("OUT_DIR", self.path("out")) + .env("PATH", self.path("bin")) + .env("TRUSTED_SERVER__TSJS_TEST_SUBPROCESS", "1") + .env("TRUSTED_SERVER__RELEASE_BUILD", "1") + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) + .env("TSJS_PREBUILT_DIR", self.path("bundles")) + .env( + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", + self.path("inventory.json"), + ); + command + } + + fn write_inventory(&self) { + fs::write( + self.path("inventory.json"), + serde_json::to_vec(&self.inventory).expect("should serialize inventory"), + ) + .expect("should write inventory"); + } + + fn rejected(&self, output: &Output, diagnostic: &str) { + let output_text = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "should reject fixture: {output_text}" + ); + assert!( + output_text.contains(diagnostic), + "should report {diagnostic}: {output_text}" + ); + assert!( + !self.path("npm-called").exists(), + "should reject before invoking npm" + ); + assert!( + !self.path("out/tsjs_modules.rs").exists(), + "should not generate a rejected module table" + ); + } + + fn add_integration(&mut self, id: &str) { + fs::create_dir(self.path(&format!("lib/src/integrations/{id}"))) + .expect("should create integration"); + fs::write( + self.path(&format!("lib/src/integrations/{id}/index.ts")), + b"export {};", + ) + .expect("should write integration entry"); + fs::write(self.path(&format!("bundles/tsjs-{id}.js")), BYTES) + .expect("should write integration bundle"); + let mut module = self.inventory["modules"][0].clone(); + module["id"] = json!(id); + module["filename"] = json!(format!("tsjs-{id}.js")); + self.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .push(module); + self.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .sort_by(|left, right| left["id"].as_str().cmp(&right["id"].as_str())); + } +} + +#[test] +fn rejects_reversed_complete_two_module_inventory() { + let mut fixture = Fixture::new(); + fixture.add_integration("aaa"); + fixture.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .reverse(); + fixture.write_inventory(); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + "inventory module identity, order, size or digest is invalid", + ); +} + +#[test] +fn rejects_unsafe_and_duplicate_core_source_integrations() { + for id in ["unsafe\"name", "core"] { + let mut fixture = Fixture::new(); + fixture.add_integration(id); + fixture.write_inventory(); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + if id == "core" { + "duplicate core module ID" + } else { + "module ID unsafe\"name must be a safe ASCII identifier" + }, + ); + } +} + +#[test] +fn accepts_safe_opaque_floats_and_negative_integers() { + let mut fixture = Fixture::new(); + fixture.inventory["tools"] = json!([ + 0.5, + 1.5e-10, + -1, + -9_007_199_254_740_991_i64, + 9_007_199_254_740_991_u64 + ]); + fixture.inventory["materials"] = json!([{"nested": [1.0, -0.0]}]); + fixture.write_inventory(); + let output = fixture.command().output().expect("should execute fixture"); + assert!( + output.status.success(), + "should accept safe opaque numeric metadata: {output:?}" + ); +} + +#[test] +#[ignore = "entry point invoked only by fixture subprocesses"] +fn fixture_entry() { + if env::var_os("TRUSTED_SERVER__TSJS_TEST_SUBPROCESS").as_deref() + != Some(std::ffi::OsStr::new("1")) + { + return; + } + build_script::run_with_hook(|source, copied| { + match env::var("TRUSTED_SERVER__TSJS_TEST_MUTATION").as_deref() { + Ok("source") => fs::write(source.join("tsjs-core.js"), b"changed bytes") + .expect("should mutate source"), + Ok("swap") => { + fs::remove_file(source.join("tsjs-core.js")).expect("should remove source"); + symlink(copied.join("tsjs-core.js"), source.join("tsjs-core.js")) + .expect("should swap source to link"); + } + Ok("private") => fs::write(copied.join("tsjs-core.js"), b"changed bytes") + .expect("should mutate copied bytes"), + Ok("extra") => { + fs::write(source.join("notes.txt"), b"extra").expect("should add source name") + } + Ok("identity") => { + fs::write(source.join("replacement.js"), BYTES) + .expect("should write replacement bytes"); + fs::rename(source.join("replacement.js"), source.join("tsjs-core.js")) + .expect("should replace source identity"); + } + Ok("directory") => { + fs::rename(source, source.with_extension("original")) + .expect("should move source directory"); + fs::create_dir(source).expect("should replace source directory"); + fs::write(source.join("tsjs-core.js"), BYTES) + .expect("should replace identical source bytes"); + } + Ok("parent") => { + let parent = source.parent().expect("should have source parent"); + let root = parent.parent().expect("should have fixture root"); + fs::remove_file(parent).expect("should remove parent link"); + symlink(root.join("other"), parent).expect("should redirect parent link"); + } + _ => {} + } + }); +} + +#[test] +fn release_accepts_checked_bundles_without_npm() { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture.command().output().expect("should execute fixture"); + assert!( + output.status.success(), + "should accept checked bundles: {output:?}" + ); + assert_eq!( + fs::read(fixture.path("out/tsjs-dist/tsjs-core.js")).expect("should read copied bundle"), + BYTES, + "should copy authenticated bytes" + ); + assert!( + !fixture.path("npm-called").exists(), + "should never invoke npm in checked-prebuilt release mode" + ); +} + +#[test] +fn ordinary_release_without_prebuilt_builds_private_bundles() { + let fixture = Fixture::new(); + fs::write(fixture.path("lib/package.json"), b"{}").expect("should write package manifest"); + fs::write(fixture.path("lib/package-lock.json"), b"{}").expect("should write package lock"); + fs::create_dir(fixture.path("lib/node_modules")).expect("should create installed dependencies"); + fs::write(fixture.path("lib/node_modules/.package-lock.json"), b"{}") + .expect("should write fresh installation marker"); + fs::write( + fixture.path("bin/npm"), + format!( + r#"#!/bin/sh +[ "$1" = run ] && [ "$2" = build ] && [ "$3" = -- ] && [ "$4" = --out-dir ] || exit 96 +/bin/mkdir -p "$5" +printf '%s' 'console.log("ordinary");' > "$5/tsjs-core.js" +output="$5" +{TRACE_BUILD}: > "$CARGO_MANIFEST_DIR/npm-called" +"# + ), + ) + .expect("should write the isolated npm build fixture"); + let output = fixture + .command() + .env_remove("TSJS_PREBUILT_DIR") + .env_remove("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY") + .output() + .expect("should execute the ordinary release fixture"); + assert!( + output.status.success(), + "should accept a fresh ordinary release build: {output:?}" + ); + assert!( + fixture.path("npm-called").exists(), + "should build instead of copying a prebuilt folder" + ); + assert_eq!( + fs::read(fixture.path("out/tsjs-dist/tsjs-core.js")).expect("should read private output"), + b"console.log(\"ordinary\");", + "should retain fresh npm bytes in the private output" + ); + assert!( + fixture.path("out/tsjs_modules.rs").is_file(), + "should generate the module table from private output" + ); +} + +#[test] +fn ignored_entry_without_explicit_subprocess_marker_does_not_write() { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__TSJS_TEST_SUBPROCESS") + .output() + .expect("should invoke ignored entry directly"); + assert!( + output.status.success(), + "should leave the direct ignored entry inert: {output:?}" + ); + assert!( + !fixture.path("out/tsjs_modules.rs").exists(), + "should never write without explicit marker" + ); +} + +#[test] +fn rejects_any_skip_presence_before_prebuilt_selection() { + for value in [ + OsString::from(""), + OsString::from("0"), + OsString::from("1"), + OsString::from_vec(vec![0xff]), + ] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env("TSJS_SKIP_BUILD", value) + .output() + .expect("should execute fixture"); + fixture.rejected(&output, "TSJS_SKIP_BUILD"); + } +} + +#[test] +fn release_requires_both_prebuilt_inputs() { + for missing in [ + "TSJS_PREBUILT_DIR", + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", + ] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env_remove(missing) + .output() + .expect("should execute fixture"); + fixture.rejected(&output, missing); + } +} + +#[test] +fn rejects_inventory_identity_and_module_tampering() { + for case in [ + "source", + "digest", + "size", + "duplicate", + "filename", + "id", + "missing", + "empty", + "extra", + "unknown", + "version", + "tools", + "bool_size", + "float_size", + "negative_size", + "module_unknown", + "missing_array", + "size_bound", + "contributions", + ] { + let mut fixture = Fixture::new(); + match case { + "source" => fixture.inventory["source_commit"] = json!("a".repeat(40)), + "digest" => fixture.inventory["modules"][0]["sha256"] = json!("a".repeat(64)), + "size" => fixture.inventory["modules"][0]["size"] = json!(BYTES.len() + 1), + "duplicate" => { + let module = fixture.inventory["modules"][0].clone(); + fixture.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .push(module); + } + "filename" => fixture.inventory["modules"][0]["filename"] = json!("../tsjs-core.js"), + "id" => fixture.inventory["modules"][0]["id"] = json!("../core"), + "missing" => fixture.inventory["modules"] = json!([]), + "empty" => fixture.inventory["modules"][0]["size"] = json!(0), + "extra" => { + let mut module = fixture.inventory["modules"][0].clone(); + module["id"] = json!("aaa"); + module["filename"] = json!("tsjs-aaa.js"); + fixture.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .push(module); + } + "unknown" => fixture.inventory["unknown"] = json!(1), + "version" => fixture.inventory["schema_version"] = json!(true), + "tools" => fixture.inventory["tools"] = json!({}), + "bool_size" => fixture.inventory["modules"][0]["size"] = json!(true), + "float_size" => fixture.inventory["modules"][0]["size"] = json!(1.0), + "negative_size" => fixture.inventory["modules"][0]["size"] = json!(-1), + "module_unknown" => fixture.inventory["modules"][0]["unknown"] = json!(1), + "missing_array" => { + fixture + .inventory + .as_object_mut() + .expect("should access inventory") + .remove("materials"); + } + "size_bound" => fixture.inventory["modules"][0]["size"] = json!(32 * 1024 * 1024 + 1), + "contributions" => fixture.inventory["modules"][0]["contributions"] = json!({}), + _ => unreachable!("should use listed case"), + } + fixture.write_inventory(); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + match case { + "source" => "inventory schema version or source does not match", + "digest" => "inventory bundle digest mismatch", + "size" => "inventory bundle size mismatch", + "missing" => "missing bundles", + "unknown" | "module_unknown" => "unknown field", + "missing_array" => "missing field", + "version" | "tools" | "bool_size" | "float_size" | "contributions" => { + "invalid type" + } + "negative_size" => "invalid value", + _ => "inventory module identity, order, size or digest is invalid", + }, + ); + } +} + +#[test] +fn rejects_inexact_source_directories() { + for case in [ + "missing", + "empty", + "extra", + "unrelated", + "directory", + "link", + "hardlink", + ] { + let fixture = Fixture::new(); + fixture.write_inventory(); + match case { + "missing" => { + fs::remove_file(fixture.path("bundles/tsjs-core.js")).expect("should remove bundle") + } + "empty" => { + fs::write(fixture.path("bundles/tsjs-core.js"), []).expect("should empty bundle") + } + "extra" | "unrelated" => fs::write( + fixture.path(if case == "extra" { + "bundles/tsjs-extra.js" + } else { + "bundles/notes.txt" + }), + BYTES, + ) + .expect("should add file"), + "directory" => { + fs::create_dir(fixture.path("bundles/extra")).expect("should add directory") + } + "link" => { + fs::remove_file(fixture.path("bundles/tsjs-core.js")) + .expect("should remove bundle"); + symlink( + fixture.path("inventory.json"), + fixture.path("bundles/tsjs-core.js"), + ) + .expect("should link bundle"); + } + "hardlink" => fs::hard_link( + fixture.path("bundles/tsjs-core.js"), + fixture.path("alias.js"), + ) + .expect("should hardlink bundle"), + _ => unreachable!("should use listed case"), + } + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + match case { + "missing" => "missing bundles", + "empty" => "empty bundles", + "extra" | "unrelated" | "directory" => "unexpected extra bundle directory entry", + "link" => "cannot open bundle", + "hardlink" => "exactly one hard link", + _ => unreachable!("should use listed case"), + }, + ); + } +} + +#[test] +fn rejects_ambiguous_or_unbounded_json() { + for case in [ + "duplicate", + "nested", + "nonfinite", + "overflow", + "integer_overflow", + "depth", + "oversize", + "bom", + "utf8", + ] { + let fixture = Fixture::new(); + let valid = serde_json::to_string(&fixture.inventory).expect("should serialize fixture"); + let raw = match case { + "duplicate" => valid + .replacen("{", "{\"schema_version\":1,", 1) + .into_bytes(), + "nested" => valid + .replace("\"tools\":[]", "\"tools\":[{\"a\":1,\"a\":2}]") + .into_bytes(), + "nonfinite" => valid + .replace("\"tools\":[]", "\"tools\":[NaN]") + .into_bytes(), + "overflow" => valid + .replace("\"tools\":[]", "\"tools\":[1e9999]") + .into_bytes(), + "integer_overflow" => valid + .replace("\"tools\":[]", "\"tools\":[18446744073709551616]") + .into_bytes(), + "depth" => valid + .replace( + "\"tools\":[]", + &format!("\"tools\":[{}0{}]", "[".repeat(65), "]".repeat(65)), + ) + .into_bytes(), + "oversize" => vec![b' '; 1024 * 1024 + 1], + "bom" => [vec![0xef, 0xbb, 0xbf], valid.into_bytes()].concat(), + "utf8" => vec![0xff], + _ => unreachable!("should use listed case"), + }; + fs::write(fixture.path("inventory.json"), raw).expect("should write malformed inventory"); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + match case { + "duplicate" | "nested" => "duplicate JSON key", + "nonfinite" | "bom" => "expected value", + "overflow" => "inventory number overflows", + "integer_overflow" => "unsafe integer", + "depth" => "JSON depth exceeds 64", + "oversize" => "inventory exceeds 1 MiB", + "utf8" => "inventory must be UTF-8", + _ => unreachable!("should use listed case"), + }, + ); + } +} + +#[test] +fn development_prebuilt_without_inventory_remains_supported() { + let fixture = Fixture::new(); + fs::write(fixture.path("bundles/manifest.json"), b"{}").expect("should write legacy manifest"); + fs::write(fixture.path("bundles/.DS_Store"), b"metadata") + .expect("should write legacy metadata"); + fs::create_dir(fixture.path("bundles/prebid")).expect("should create legacy tree"); + fs::write(fixture.path("bundles/prebid/package.json"), b"{}") + .expect("should write legacy package output"); + fs::write(fixture.path("bundles/prebid/prebid.js"), BYTES) + .expect("should write legacy external bundle output"); + symlink( + fixture.path("bundles/prebid"), + fixture.path("bundles/legacy-link"), + ) + .expect("should create legacy directory alias"); + fs::hard_link( + fixture.path("bundles/tsjs-core.js"), + fixture.path("alias.js"), + ) + .expect("should retain legacy bundle hard links"); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY") + .output() + .expect("should execute fixture"); + assert!( + output.status.success(), + "should support development prebuilt bundles: {output:?}" + ); +} + +#[test] +fn rejects_source_or_copied_bytes_changed_during_copy() { + for mutation in [ + "source", + "swap", + "private", + "extra", + "identity", + "directory", + ] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env("TRUSTED_SERVER__TSJS_TEST_MUTATION", mutation) + .output() + .expect("should execute fixture"); + fixture.rejected( + &output, + match mutation { + "source" => "changed during copy", + "swap" => "cannot open bundle", + "private" => "private copied bundle does not match", + "extra" => "unexpected extra bundle directory entry", + "identity" | "directory" => "identity changed during copy", + _ => unreachable!("should use listed mutation"), + }, + ); + } +} + +#[test] +fn rejects_release_controls_and_relative_paths_before_npm() { + for (variable, value) in [ + ("TRUSTED_SERVER__RELEASE_BUILD", ""), + ("TRUSTED_SERVER__RELEASE_BUILD", "0"), + ("TRUSTED_SERVER__RELEASE_BUILD", "true"), + ("TRUSTED_SERVER__GIT_VERSION", ""), + ("TRUSTED_SERVER__GIT_VERSION", "abcdef"), + ("TSJS_PREBUILT_DIR", "bundles"), + ("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", "inventory.json"), + ] { + let fixture = Fixture::new(); + fixture.write_inventory(); + fixture.rejected( + &fixture + .command() + .env(variable, value) + .output() + .expect("should execute fixture"), + match variable { + "TRUSTED_SERVER__RELEASE_BUILD" => { + "TRUSTED_SERVER__RELEASE_BUILD must be exactly 1" + } + "TRUSTED_SERVER__GIT_VERSION" if value.is_empty() => { + "release source must be exactly 40" + } + "TRUSTED_SERVER__GIT_VERSION" => "release source must be exactly 40", + _ => "must be an absolute path", + }, + ); + } + let fixture = Fixture::new(); + fixture.write_inventory(); + fixture.rejected( + &fixture + .command() + .env_remove("TRUSTED_SERVER__GIT_VERSION") + .output() + .expect("should execute fixture"), + "TRUSTED_SERVER__GIT_VERSION", + ); +} + +#[test] +fn development_inventory_checks_bundle_bytes_without_comparing_display_source() { + for identity in ["main", "v1.2.3", "a".repeat(40).as_str()] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env("TRUSTED_SERVER__GIT_VERSION", identity) + .output() + .expect("should execute development fixture"); + assert!( + output.status.success(), + "should permit development display identity: {output:?}" + ); + } + { + let fixture = Fixture::new(); + fixture.write_inventory(); + let mut command = fixture.command(); + command.env_remove("TRUSTED_SERVER__RELEASE_BUILD"); + fs::write(fixture.path("bundles/tsjs-core.js"), b"tampered bytes") + .expect("should change bundle"); + fixture.rejected( + &command.output().expect("should execute fixture"), + "inventory bundle size mismatch", + ); + } + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TRUSTED_SERVER__GIT_VERSION") + .output() + .expect("should execute fixture"); + assert!( + output.status.success(), + "should validate development inventory without claiming approved source: {output:?}" + ); +} + +#[test] +fn rejects_skip_presence_in_development_source_and_prebuilt_modes() { + for prebuilt in [true, false] { + for value in ["", "0", "1"] { + let fixture = Fixture::new(); + let mut command = fixture.command(); + command + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY") + .env("TSJS_SKIP_BUILD", value); + if !prebuilt { + command.env_remove("TSJS_PREBUILT_DIR"); + } + fixture.rejected( + &command.output().expect("should execute fixture"), + "TSJS_SKIP_BUILD", + ); + } + } +} + +#[test] +fn development_source_build_uses_fresh_npm_output() { + let fixture = Fixture::new(); + fs::write(fixture.path("lib/package.json"), json!({}).to_string()) + .expect("should write package fixture"); + fs::create_dir(fixture.path("lib/node_modules")) + .expect("should create installed dependencies fixture"); + fs::write(fixture.path("lib/node_modules/.package-lock.json"), b"{}") + .expect("should write fresh installation marker"); + fs::create_dir(fixture.path("dist")).expect("should create stale old dist"); + fs::write(fixture.path("dist/tsjs-core.js"), b"stale bytes") + .expect("should write stale bundle"); + fs::write(fixture.path("bin/npm"), format!("#!/bin/sh\n: > \"$CARGO_MANIFEST_DIR/npm-called\"\nfor argument in \"$@\"; do output=\"$argument\"; done\n/bin/mkdir -p \"$output\"\nprintf 'fresh source bytes' > \"$output/tsjs-core.js\"\n{TRACE_BUILD}")).expect("should write source builder fixture"); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TSJS_PREBUILT_DIR") + .env_remove("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY") + .output() + .expect("should execute fixture"); + assert!( + output.status.success(), + "should support ordinary fresh development build: {output:?}" + ); + assert_eq!( + fs::read(fixture.path("out/tsjs-dist/tsjs-core.js")).expect("should read fresh bundle"), + b"fresh source bytes", + "should embed current npm output" + ); + assert!( + fixture.path("npm-called").exists(), + "should invoke npm for development source build" + ); +} + +#[test] +fn inventory_sorting_preserves_core_first_runtime_order() { + let mut fixture = Fixture::new(); + fs::create_dir(fixture.path("lib/src/integrations/aaa")).expect("should create integration"); + fs::write( + fixture.path("lib/src/integrations/aaa/index.ts"), + b"export {};", + ) + .expect("should write source entry"); + fs::write(fixture.path("bundles/tsjs-aaa.js"), BYTES).expect("should write integration bundle"); + let mut module = fixture.inventory["modules"][0].clone(); + module["id"] = json!("aaa"); + module["filename"] = json!("tsjs-aaa.js"); + fixture.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .insert(0, module); + fixture.write_inventory(); + let output = fixture.command().output().expect("should execute fixture"); + assert!( + output.status.success(), + "should accept sorted inventory independently of runtime order: {output:?}" + ); + let generated = fs::read_to_string(fixture.path("out/tsjs_modules.rs")) + .expect("should read generated code"); + assert!( + generated.find("id: \"core\"") < generated.find("id: \"aaa\""), + "should retain core first runtime order" + ); +} + +#[test] +fn rejects_inventory_inside_source_and_private_destination_links() { + for case in ["inside", "link", "file"] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let mut command = fixture.command(); + match case { + "inside" => { + fs::copy( + fixture.path("inventory.json"), + fixture.path("bundles/inventory.json"), + ) + .expect("should place inventory inside source"); + command.env( + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", + fixture.path("bundles/inventory.json"), + ); + } + "link" => symlink(fixture.path("bundles"), fixture.path("out/tsjs-dist")) + .expect("should link private destination"), + "file" => fs::write(fixture.path("out/tsjs-dist"), b"existing file") + .expect("should write conflicting destination"), + _ => unreachable!("should use listed case"), + } + fixture.rejected( + &command.output().expect("should execute fixture"), + match case { + "inside" => "inventory must be outside", + "link" | "file" => "private bundle destination must be a directory", + _ => unreachable!("should use listed case"), + }, + ); + assert_eq!( + fs::read(fixture.path("bundles/tsjs-core.js")).expect("should preserve input bundle"), + BYTES, + "should preserve source when rejecting destination" + ); + } +} + +#[test] +fn emits_rerun_directives_for_inventory_and_release_inputs() { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture.command().output().expect("should execute fixture"); + assert!(output.status.success(), "should accept checked fixture"); + let stdout = String::from_utf8(output.stdout).expect("should capture UTF-8 directives"); + for variable in [ + "TSJS_SKIP_BUILD", + "TSJS_PREBUILT_DIR", + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", + "TRUSTED_SERVER__RELEASE_BUILD", + "TRUSTED_SERVER__GIT_VERSION", + ] { + assert!( + stdout.contains(&format!("cargo:rerun-if-env-changed={variable}")), + "should watch {variable}" + ); + } + for path in [ + "lib/build-module-options.mjs", + "lib/release-composition.mjs", + "lib/vitest.config.ts", + ] { + assert!( + stdout.contains(&format!("cargo:rerun-if-changed={path}")), + "should watch JS build and test configuration input {path}" + ); + } + for path in [fixture.path("inventory.json"), fixture.path("bundles")] { + assert!( + stdout.contains(&format!("cargo:rerun-if-changed={}", path.display())), + "should watch prebuilt input path" + ); + } +} + +#[test] +fn exposes_raw_inventory_digest_for_later_cargo_build_evidence() { + let fixture = Fixture::new(); + let mut raw = serde_json::to_vec(&fixture.inventory).expect("should serialize inventory"); + raw.extend_from_slice(b"\n \n"); + fs::write(fixture.path("inventory.json"), &raw).expect("should write raw inventory"); + + let output = fixture.command().output().expect("should execute fixture"); + + assert!( + output.status.success(), + "should accept whitespace-preserving inventory" + ); + let stdout = String::from_utf8(output.stdout).expect("should capture UTF-8 directives"); + assert!( + stdout.contains(&format!( + "cargo:rustc-env=TRUSTED_SERVER__TSJS_INVENTORY_SHA256={}", + hex::encode(Sha256::digest(&raw)) + )), + "should report digest of the exact held inventory bytes: {stdout}" + ); +} + +#[test] +fn development_inventory_does_not_compare_non_unicode_display_source() { + let fixture = Fixture::new(); + fixture.write_inventory(); + + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env( + "TRUSTED_SERVER__GIT_VERSION", + OsString::from_vec(vec![0xff]), + ) + .output() + .expect("should execute fixture"); + + assert!( + output.status.success(), + "should leave development identity resolution to core: {output:?}" + ); +} + +#[test] +fn rejects_source_leaf_links_with_lexical_directory_suffixes() { + for suffix in ["", "/", "//", "/.", "/./"] { + let fixture = Fixture::new(); + fixture.write_inventory(); + symlink(fixture.path("bundles"), fixture.path("bundles-link")) + .expect("should link source directory"); + let output = fixture + .command() + .env( + "TSJS_PREBUILT_DIR", + format!("{}{suffix}", fixture.path("bundles-link").display()), + ) + .output() + .expect("should execute linked source fixture"); + fixture.rejected(&output, "bundles-link"); + } +} + +#[test] +fn rejects_unsafe_integral_opaque_numbers() { + for literal in ["9007199254740993", "-9007199254740993", "1e20", "1.5e300"] { + for field in ["tools", "materials", "contributions"] { + let fixture = Fixture::new(); + let raw = serde_json::to_string(&fixture.inventory) + .expect("should serialize inventory") + .replace( + &format!("\"{field}\":[]"), + &format!("\"{field}\":[{{\"nested\":{literal}}}]"), + ); + fs::write(fixture.path("inventory.json"), raw).expect("should write opaque number"); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + "unsafe integer", + ); + } + } +} + +#[test] +fn permits_exact_inventory_size_and_depth_boundaries() { + let mut fixture = Fixture::new(); + let mut value = json!(0); + for _ in 0..62 { + value = json!([value]); + } + fixture.inventory["tools"] = json!([value]); + let mut raw = + serde_json::to_vec(&fixture.inventory).expect("should serialize boundary inventory"); + raw.resize(1024 * 1024, b' '); + fs::write(fixture.path("inventory.json"), raw).expect("should write boundary inventory"); + + let output = fixture.command().output().expect("should execute fixture"); + + assert!( + output.status.success(), + "should accept inventory at byte and container-depth limits: {output:?}" + ); +} + +#[test] +fn permits_parent_links_when_the_held_source_identity_stays_fixed() { + let fixture = Fixture::new(); + fixture.write_inventory(); + symlink(fixture.root.path(), fixture.path("parent-alias")).expect("should create parent alias"); + + let output = fixture + .command() + .env("TSJS_PREBUILT_DIR", fixture.path("parent-alias/bundles")) + .output() + .expect("should execute fixture"); + + assert!( + output.status.success(), + "should anchor copying to unchanged directory identity through a parent alias: {output:?}" + ); +} + +#[test] +fn rejects_parent_link_redirection_to_byte_identical_directory() { + let fixture = Fixture::new(); + fixture.write_inventory(); + symlink(fixture.root.path(), fixture.path("parent-alias")).expect("should create parent alias"); + fs::create_dir_all(fixture.path("other/bundles")) + .expect("should create other source directory"); + fs::write(fixture.path("other/bundles/tsjs-core.js"), BYTES) + .expect("should write identical other bytes"); + + let output = fixture + .command() + .env("TSJS_PREBUILT_DIR", fixture.path("parent-alias/bundles")) + .env("TRUSTED_SERVER__TSJS_TEST_MUTATION", "parent") + .output() + .expect("should execute fixture"); + + fixture.rejected(&output, "identity changed"); +} diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml new file mode 100644 index 0000000000..7733133cdc --- /dev/null +++ b/crates/trusted-server-release/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "trusted-server-release" +edition.workspace = true +license.workspace = true +publish.workspace = true +version.workspace = true +rust-version = "1.98" + +[dependencies] +clap.workspace = true +derive_more.workspace = true +error-stack.workspace = true +flate2.workspace = true +hex.workspace = true +libc.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +tar = { version = "=0.4.46", default-features = false } +time = { workspace = true, features = ["parsing"] } +toml.workspace = true +wasmparser = { version = "=0.247.0", default-features = false, features = [ + "features", + "simd", + "std", + "validate", +] } + +[lints] +workspace = true diff --git a/crates/trusted-server-release/src/documents.rs b/crates/trusted-server-release/src/documents.rs new file mode 100644 index 0000000000..b84c9a86b8 --- /dev/null +++ b/crates/trusted-server-release/src/documents.rs @@ -0,0 +1,432 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::Path; + +use error_stack::Report; +use serde::Serialize; +use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::ser::{SerializeMap as _, SerializeSeq as _}; +use serde_json::{Map, Number, Value}; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; +/// Byte-limit class selected before a document is read or parsed. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Role { + Metadata, + Source, + Companion, + Spdx, + Executable, + /// One Cargo `--message-format=json` line. + CargoMessage, + /// Internal Cargo observations; never a published metadata contract. + CargoObservation, + /// One immutable hosted artifact ZIP, checked before narrow parsing. + ArtifactArchive, + /// One private ordinary observation ZIP, bounded before allocation. + ObservationArchive, +} + +impl Role { + /// Maximum bytes permitted for this document role. + pub(crate) fn limit(self) -> u64 { + match self { + Self::Metadata | Self::CargoMessage => 1024 * 1024, + Self::Source => 64 * 1024 * 1024, + Self::Companion => 32 * 1024 * 1024, + Self::Spdx | Self::CargoObservation => 16 * 1024 * 1024, + Self::Executable => 512 * 1024 * 1024, + Self::ArtifactArchive => 768 * 1024 * 1024, + Self::ObservationArchive => 2 * 1024 * 1024, + } + } +} + +/// Parses bounded strict JSON with unique keys and finite numeric tokens. +pub(crate) fn parse_json(bytes: &[u8], role: Role) -> Result> { + if bytes.len() as u64 > role.limit() { + return Err(invalid("document exceeds role byte limit")); + } + let text = std::str::from_utf8(bytes).map_err(|_| invalid("document must be UTF-8"))?; + if text.starts_with('\u{feff}') { + return Err(invalid("document must not start with a BOM")); + } + validate_numbers(text)?; + let mut parser = serde_json::Deserializer::from_str(text); + let value = StrictSeed { depth: 1 } + .deserialize(&mut parser) + .map_err(invalid)?; + parser.end().map_err(invalid)?; + Ok(value) +} + +/// Exact bytes read from one held descriptor and their SHA-256. +pub(crate) struct RawBytes { + pub(crate) bytes: Vec, + pub(crate) sha256: String, +} + +/// Exact bounded file bytes measured without retaining their body in memory. +pub(crate) struct FileDigest { + pub(crate) sha256: String, + pub(crate) size: u64, + pub(crate) mode: u32, +} + +/// Reads one unlinked regular file without following a leaf symlink. +pub(crate) fn read_bounded(path: &Path, role: Role) -> Result> { + read_bounded_with_check(path, role, &|| Ok(())) +} + +/// Reads bounded bytes while checking the owning attempt between 64 KiB chunks. +/// +/// # Errors +/// +/// Returns an error for cancellation, expired limits, unsafe files, changed +/// metadata or paths, or bytes exceeding the role bound before allocation. +pub(crate) fn read_bounded_with_check( + path: &Path, + role: Role, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + check()?; + let file = open_input(path)?; + let before = file.metadata().map_err(invalid)?; + let raw = read_held_with_check(&file, &before, role, check)?; + verify_input_path(path, &before)?; + check()?; + Ok(raw) +} + +/// Hashes a bounded held file with constant-sized buffers and attempt checks. +/// +/// # Errors +/// +/// Returns an error for unsafe files, changed metadata or paths, an exceeded +/// byte limit, cancellation or an expired owning attempt. +pub(crate) fn hash_bounded_with_check( + path: &Path, + limit: u64, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + check()?; + let file = open_input(path)?; + let before = file.metadata().map_err(invalid)?; + let digest = consume_held(&file, &before, limit, check, &mut |_| Ok(()))?; + verify_input_path(path, &before)?; + check()?; + Ok(digest) +} + +fn open_input(path: &Path) -> Result> { + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(invalid) +} + +fn verify_input_path(path: &Path, before: &std::fs::Metadata) -> Result<(), Report> { + let current = std::fs::symlink_metadata(path).map_err(invalid)?; + if !current.is_file() || stamp(before) != stamp(¤t) { + return Err(invalid("input path changes while reading held descriptor")); + } + Ok(()) +} + +#[cfg(test)] +fn read_held( + file: &File, + before: &std::fs::Metadata, + role: Role, +) -> Result> { + read_held_with_check(file, before, role, &|| Ok(())) +} + +fn read_held_with_check( + file: &File, + before: &std::fs::Metadata, + role: Role, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + check()?; + validate_held(before, role.limit())?; + let capacity = usize::try_from(before.len()).map_err(invalid)?; + let mut bytes = Vec::new(); + bytes.try_reserve_exact(capacity).map_err(invalid)?; + let digest = consume_held(file, before, role.limit(), check, &mut |chunk| { + bytes.extend_from_slice(chunk); + Ok(()) + })?; + Ok(RawBytes { + bytes, + sha256: digest.sha256, + }) +} + +fn validate_held(before: &std::fs::Metadata, limit: u64) -> Result<(), Report> { + if !before.is_file() || before.nlink() != 1 { + return Err(invalid("input must be a regular file with one hard link")); + } + if before.len() > limit { + return Err(invalid("input exceeds role byte limit")); + } + Ok(()) +} + +fn consume_held( + file: &File, + before: &std::fs::Metadata, + limit: u64, + check: &dyn Fn() -> Result<(), Report>, + consume: &mut impl FnMut(&[u8]) -> Result<(), Report>, +) -> Result> { + check()?; + validate_held(before, limit)?; + let mut input = file; + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + let mut digest = Sha256::new(); + loop { + check()?; + let count = input.read(&mut buffer).map_err(invalid)?; + check()?; + if count == 0 { + break; + } + size = size + .checked_add(count as u64) + .ok_or_else(|| invalid("input length overflows"))?; + if size > before.len() || size > limit { + return Err(invalid("input grows while reading held descriptor")); + } + digest.update(&buffer[..count]); + consume(&buffer[..count])?; + } + let after = file.metadata().map_err(invalid)?; + if stamp(before) != stamp(&after) || size != before.len() { + return Err(invalid("input changes while reading held descriptor")); + } + check()?; + Ok(FileDigest { + sha256: hex::encode(digest.finalize()), + size, + mode: before.permissions().mode(), + }) +} + +fn stamp(metadata: &std::fs::Metadata) -> (u64, u64, u64, u64, i64, i64, i64, i64, u32) { + ( + metadata.dev(), + metadata.ino(), + metadata.nlink(), + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + metadata.permissions().mode(), + ) +} +struct LimitedBytes { + bytes: Vec, + limit: u64, +} + +impl io::Write for LimitedBytes { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() as u64 > self.limit.saturating_sub(self.bytes.len() as u64) { + return Err(io::Error::other("output exceeds role byte limit")); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct Canonical<'a> { + value: &'a Value, + depth: usize, +} + +impl Serialize for Canonical<'_> { + fn serialize(&self, serializer: S) -> Result { + if self.depth > 64 && matches!(self.value, Value::Object(_) | Value::Array(_)) { + return Err(serde::ser::Error::custom("JSON container depth exceeds 64")); + } + match self.value { + Value::Object(values) => { + let ordered: BTreeMap<_, _> = values.iter().collect(); + let mut map = serializer.serialize_map(Some(ordered.len()))?; + for (key, value) in ordered { + map.serialize_entry( + key, + &Canonical { + value, + depth: self.depth + 1, + }, + )?; + } + map.end() + } + Value::Array(values) => { + let mut sequence = serializer.serialize_seq(Some(values.len()))?; + for value in values { + sequence.serialize_element(&Canonical { + value, + depth: self.depth + 1, + })?; + } + sequence.end() + } + scalar => scalar.serialize(serializer), + } + } +} + +/// Serializes JSON with sorted keys and a trailing newline. +pub(crate) fn canonical_json(value: &Value, role: Role) -> Result, Report> { + let mut output = LimitedBytes { + bytes: Vec::new(), + limit: role.limit(), + }; + serde_json::to_writer(&mut output, &Canonical { value, depth: 1 }).map_err(invalid)?; + output.write_all(b"\n").map_err(invalid)?; + Ok(output.bytes) +} + +fn invalid(message: impl fmt::Display) -> Report { + Report::new(ReleaseError::Document).attach(message.to_string()) +} + +// Serde may otherwise convert overflowing integer tokens to finite floats. +fn validate_numbers(text: &str) -> Result<(), Report> { + let bytes = text.as_bytes(); + let mut cursor = 0; + while cursor < bytes.len() { + if bytes[cursor] == b'"' { + cursor += 1; + while cursor < bytes.len() { + match bytes[cursor] { + b'\\' => cursor += 2, + b'"' => { + cursor += 1; + break; + } + _ => cursor += 1, + } + } + } else if bytes[cursor] == b'-' || bytes[cursor].is_ascii_digit() { + let start = cursor; + while cursor < bytes.len() + && matches!( + bytes[cursor], + b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9' + ) + { + cursor += 1; + } + let token = &text[start..cursor]; + let valid = if token.contains(['.', 'e', 'E']) { + token.parse::().is_ok_and(f64::is_finite) + } else if token.starts_with('-') { + token.parse::().is_ok() + } else { + token.parse::().is_ok() + }; + if !valid { + return Err(invalid("JSON number exceeds its finite numeric range")); + } + } else { + cursor += 1; + } + } + Ok(()) +} + +struct StrictSeed { + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for StrictSeed { + type Value = Value; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for StrictSeed { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("JSON with unique object keys") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_f64(self, value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("nonfinite number")) + } + + fn visit_seq>(self, mut access: A) -> Result { + if self.depth > 64 { + return Err(serde::de::Error::custom("JSON container depth exceeds 64")); + } + let mut values = Vec::new(); + while let Some(value) = access.next_element_seed(StrictSeed { + depth: self.depth + 1, + })? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map>(self, mut access: A) -> Result { + if self.depth > 64 { + return Err(serde::de::Error::custom("JSON container depth exceeds 64")); + } + let mut values = Map::new(); + while let Some(key) = access.next_key::()? { + if values.contains_key(&key) { + return Err(serde::de::Error::custom("duplicate object key")); + } + values.insert( + key, + access.next_value_seed(StrictSeed { + depth: self.depth + 1, + })?, + ); + } + Ok(Value::Object(values)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/documents/tests.rs b/crates/trusted-server-release/src/documents/tests.rs new file mode 100644 index 0000000000..4b166d770e --- /dev/null +++ b/crates/trusted-server-release/src/documents/tests.rs @@ -0,0 +1,394 @@ +use std::cell::Cell; +use std::ffi::CString; +use std::fs; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::symlink; + +use serde_json::json; +use sha2::Sha256; +use tempfile::tempdir; + +use super::*; + +#[test] +fn controlled_reads_cancel_before_and_between_bounded_chunks() { + let root = tempdir().expect("should create controlled input"); + let path = root.path().join("input"); + fs::write(&path, vec![7_u8; 4 * 64 * 1024]).expect("should write multiple chunks"); + let calls = Cell::new(0); + let check = || { + calls.set(calls.get() + 1); + if calls.get() >= 6 { + return Err(invalid("cancelled fixture")); + } + Ok(()) + }; + assert!( + read_bounded_with_check(&path, Role::Executable, &check).is_err(), + "should cancel during bounded read" + ); + assert_eq!( + calls.get(), + 6, + "should stop between the first and second chunks" + ); + let before = || Err(invalid("already cancelled fixture")); + assert!( + hash_bounded_with_check(&path, 1024 * 1024, &before).is_err(), + "should reject before hashing" + ); +} + +#[test] +fn controlled_stream_hash_enforces_limit_and_exact_digest() { + let root = tempdir().expect("should create hash input"); + let path = root.path().join("input"); + let bytes = vec![9_u8; 3 * 64 * 1024]; + fs::write(&path, &bytes).expect("should write hash input"); + let measured = hash_bounded_with_check(&path, bytes.len() as u64, &|| Ok(())) + .expect("should stream exact bytes"); + assert_eq!( + measured.sha256, + hex::encode(Sha256::digest(&bytes)), + "should hash all original bytes" + ); + assert_eq!( + measured.size, + bytes.len() as u64, + "should retain original size" + ); + assert_eq!( + measured.mode, + fs::metadata(&path) + .expect("should inspect measured mode") + .mode(), + "should retain mode from the same held input" + ); + assert!( + hash_bounded_with_check(&path, bytes.len() as u64 - 1, &|| Ok(())).is_err(), + "should reject oversized files before reading" + ); +} + +#[test] +fn controlled_stream_hash_detects_mutation_and_leaf_replacement() { + let root = tempdir().expect("should create changing input"); + let path = root.path().join("input"); + let bytes = vec![7_u8; 3 * 64 * 1024]; + fs::write(&path, &bytes).expect("should write original input"); + let calls = Cell::new(0); + let result = hash_bounded_with_check(&path, bytes.len() as u64, &|| { + calls.set(calls.get() + 1); + if calls.get() == 4 { + fs::write(&path, b"changed").expect("should mutate held input"); + } + Ok(()) + }); + assert!( + result.is_err(), + "should reject mutations between hash chunks" + ); + fs::write(&path, &bytes).expect("should restore input fixture"); + let calls = Cell::new(0); + let result = hash_bounded_with_check(&path, bytes.len() as u64, &|| { + calls.set(calls.get() + 1); + if calls.get() == 4 { + fs::rename(&path, root.path().join("retained")).expect("should replace leaf name"); + fs::write(&path, &bytes).expect("should write identical replacement bytes"); + } + Ok(()) + }); + assert!( + result.is_err(), + "should reject changed path identity even with matching bytes" + ); +} + +fn parse(bytes: &[u8]) -> Result> { + parse_json(bytes, Role::Metadata) +} + +#[test] +fn rejects_duplicate_keys_at_every_depth() { + for depth in 0..64 { + let input = format!( + "{}{{\"same\":1,\"same\":2}}{}", + "[".repeat(depth), + "]".repeat(depth) + ); + assert!( + parse(input.as_bytes()).is_err(), + "should reject duplicate at depth {depth}" + ); + } +} + +#[test] +fn rejects_duplicate_decoded_keys_in_opaque_objects() { + assert!( + parse(br#"{"opaque":{"x":0,"\u0078":1}}"#).is_err(), + "should reject escaped duplicate key" + ); +} + +#[test] +fn rejects_top_level_integer_overflow() { + for input in ["18446744073709551616", "-9223372036854775809"] { + assert!( + parse(input.as_bytes()).is_err(), + "should reject integer overflow: {input}" + ); + } +} + +#[test] +fn rejects_opaque_integer_overflow() { + for input in ["18446744073709551616", "-9223372036854775809"] { + let bytes = format!("{{\"opaque\":[{{\"value\":{input}}}]}}"); + assert!( + parse(bytes.as_bytes()).is_err(), + "should reject opaque integer overflow" + ); + } +} + +#[test] +fn rejects_nonfinite_and_decimal_exponent_overflow() { + for input in [ + "NaN", + "Infinity", + "-Infinity", + "1e309", + "-1e309", + "1.8e308", + "9".repeat(400).as_str(), + ] { + assert!( + parse(input.as_bytes()).is_err(), + "should reject nonfinite or overflowing number" + ); + } + for number in [ + format!("{}.0", "9".repeat(400)), + "1e999".to_owned(), + "-1.8e308".to_owned(), + ] { + let opaque = format!("{{\"opaque\":{{\"number\":{number}}}}}"); + assert!( + parse(opaque.as_bytes()).is_err(), + "should reject decimal/exponent overflow in opaque values" + ); + } +} + +#[test] +fn retains_exact_integer_boundaries() { + assert_eq!( + parse(b"18446744073709551615") + .expect("should accept u64 boundary") + .as_u64(), + Some(u64::MAX), + "should retain exact u64" + ); + assert_eq!( + parse(b"-9223372036854775808") + .expect("should accept i64 boundary") + .as_i64(), + Some(i64::MIN), + "should retain exact i64" + ); +} + +#[test] +fn number_like_quoted_text_and_escapes_remain_text() { + let value = json!({"text": "18446744073709551616 -9223372036854775809 1e999 \" quoted \\ backslash \\\" 1.8e308", "nested": ["9".repeat(400)]}); + let bytes = serde_json::to_vec(&value).expect("should serialize text controls"); + assert_eq!( + parse(&bytes).expect("should preserve number-like text"), + value, + "should skip strings including escaped quotes and backslashes" + ); +} + +#[test] +fn rejects_invalid_utf8_before_deserialization() { + let error = parse(b"\"\xff\"").expect_err("should reject invalid UTF-8"); + assert!( + format!("{error:?}").contains("UTF-8"), + "should diagnose byte encoding before deserialization" + ); +} + +#[test] +fn rejects_bom_before_deserialization() { + let error = parse(b"\xef\xbb\xbf{}").expect_err("should reject BOM"); + assert!( + format!("{error:?}").contains("BOM"), + "should diagnose BOM before deserialization" + ); +} + +#[test] +fn rejects_surrogates_and_trailing_controls() { + for input in [ + br#""\ud800""#.as_slice(), + br#""\udfff""#, + b"{}\0", + b"{}\x0b", + b"{}\x1f", + b"{}{}", + ] { + assert!( + parse(input).is_err(), + "should reject surrogate or trailing data" + ); + } +} + +#[test] +fn rejects_container_depth_65_including_root() { + for (open, close) in [("[", "]"), ("{\"nested\":", "}")] { + let good = format!("{}0{}", open.repeat(64), close.repeat(64)); + let bad = format!("{}0{}", open.repeat(65), close.repeat(65)); + assert!( + parse(good.as_bytes()).is_ok(), + "should accept 64 containers" + ); + assert!( + parse(bad.as_bytes()).is_err(), + "should reject 65 containers" + ); + } +} + +#[test] +fn rejects_oversized_json_before_encoding_or_parsing() { + for role in [Role::Metadata, Role::Companion, Role::Spdx] { + let bytes = vec![0xff; role.limit() as usize + 1]; + let error = parse_json(&bytes, role).expect_err("should reject role byte overflow"); + assert!( + format!("{error:?}").contains("byte limit"), + "should enforce size before encoding" + ); + } +} + +#[test] +fn rejects_oversized_files_before_reading() { + let root = tempdir().expect("should create fixtures"); + for role in [ + Role::Metadata, + Role::Companion, + Role::Companion, + Role::Spdx, + Role::Executable, + ] { + let path = root.path().join("oversized"); + let file = fs::File::create(&path).expect("should create sparse oversized file"); + file.set_len(role.limit() + 1).expect("should size fixture"); + assert!( + read_bounded(&path, role).is_err(), + "should reject oversized role before read" + ); + } +} + +#[test] +fn retains_hash_of_exact_original_bytes() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("source.json"); + let bytes = b" { \"a\" : 1 } \n"; + fs::write(&path, bytes).expect("should write original bytes"); + let raw = read_bounded(&path, Role::Metadata).expect("should read exact bytes"); + assert_eq!(raw.bytes, bytes, "should retain original bytes"); + assert_eq!( + raw.sha256, + hex::encode(Sha256::digest(bytes)), + "should hash original bytes before normalization" + ); +} + +#[test] +fn rejects_symlinks_hardlinks_and_directories_at_read() { + let root = tempdir().expect("should create fixtures"); + let real = root.path().join("real"); + fs::write(&real, b"{}").expect("should write fixture"); + let link = root.path().join("symlink"); + symlink(&real, &link).expect("should create symlink"); + assert!( + read_bounded(&link, Role::Metadata).is_err(), + "should reject symbolic leaf" + ); + let hard = root.path().join("hardlink"); + fs::hard_link(&real, &hard).expect("should create hardlink"); + assert_eq!( + fs::metadata(&hard).expect("should inspect links").nlink(), + 2, + "should exercise hardlink fixture" + ); + assert!( + read_bounded(&hard, Role::Metadata).is_err(), + "should reject hardlink" + ); + assert!( + read_bounded(root.path(), Role::Metadata).is_err(), + "should reject directory" + ); +} + +#[test] +fn canonical_serialization_sorts_recursively_and_adds_one_newline() { + let value = json!({"z":[{"z": "é", "a":true}], "a":0}); + let bytes = canonical_json(&value, Role::Metadata).expect("should serialize canonical bytes"); + assert_eq!( + bytes, + "{\"a\":0,\"z\":[{\"a\":true,\"z\":\"é\"}]}\n".as_bytes(), + "should sort recursively and retain compact UTF-8" + ); +} + +#[test] +fn detects_mutation_on_held_input_descriptor() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("held"); + fs::write(&path, b"old").expect("should create input"); + let file = File::open(&path).expect("should hold original descriptor"); + let before = file.metadata().expect("should capture original metadata"); + fs::write(&path, b"new").expect("should mutate same-length input"); + assert!( + read_held(&file, &before, Role::Metadata).is_err(), + "should detect same-length mutation against captured metadata" + ); +} + +#[test] +fn held_read_does_not_reopen_replaced_path() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("held"); + fs::write(&path, b"original").expect("should create input"); + let file = File::open(&path).expect("should hold original descriptor"); + let before = file.metadata().expect("should capture input metadata"); + fs::remove_file(&path).expect("should remove input pathname"); + fs::write(&path, b"replacement").expect("should create different file"); + assert!( + read_held(&file, &before, Role::Metadata).is_err(), + "should reject removed held file rather than reopen replacement" + ); +} + +#[test] +fn rejects_fifo_without_waiting_for_a_writer() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("input-fifo"); + let name = CString::new(path.as_os_str().as_bytes()).expect("should encode fixture pathname"); + // The CString supplies a valid NUL-terminated pathname for this Unix syscall. + assert_eq!( + unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, + 0, + "should create FIFO fixture" + ); + assert!( + read_bounded(&path, Role::Metadata).is_err(), + "should reject FIFO without blocking on open or read" + ); +} diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs new file mode 100644 index 0000000000..d0280f7afd --- /dev/null +++ b/crates/trusted-server-release/src/error.rs @@ -0,0 +1,20 @@ +/// Reports a closed release execution gate or invalid release input. +#[derive(Debug, derive_more::Display)] +pub(crate) enum ReleaseError { + #[display("Invalid release tool invocation")] + Usage, + #[display("Invalid release document")] + Document, + #[display("Ordinary Fastly rebuild bytes differ")] + RebuildMismatch, + #[display("Ordinary build evidence does not join the current run")] + RebuildIdentity, + #[display("Final artifact inspection rejected the executable")] + Inspect, + #[display("Bounded release child process failed")] + Process, + #[display("Release recipe inputs or execution plan are rejected")] + Recipe, +} + +impl core::error::Error for ReleaseError {} diff --git a/crates/trusted-server-release/src/inspect.rs b/crates/trusted-server-release/src/inspect.rs new file mode 100644 index 0000000000..76144fd359 --- /dev/null +++ b/crates/trusted-server-release/src/inspect.rs @@ -0,0 +1,206 @@ +//! Pure inspection of final Fastly Wasm bytes. +mod wasm; +use crate::error::ReleaseError; +use error_stack::Report; +use sha2::{Digest as _, Sha256}; +use std::fmt; +pub(crate) use wasm::inspect_wasm; + +/// The Wasm inspection rule violated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum Violation { + #[display("artifact is not a core WebAssembly module")] + Encoding, + #[display("core WebAssembly module fails validation")] + Validation, + #[display("WebAssembly import is outside the reviewed host ABI")] + Import, + #[display("WebAssembly module lacks a required nonempty debug section")] + #[cfg(test)] + DebugInfo, + #[display("WebAssembly module lacks the Fastly Compute entry ABI")] + #[cfg(test)] + Abi, +} + +fn rejected(violation: Violation, detail: impl fmt::Display) -> Report { + Report::new(ReleaseError::Inspect) + .attach(violation) + .attach(detail.to_string()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// One custom section observed in a core Wasm module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WasmCustomSection { + name: String, + size: usize, +} + +#[cfg(test)] +impl WasmCustomSection { + /// Section name as encoded in the module. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Payload length in bytes, excluding the name. + pub(crate) fn size(&self) -> usize { + self.size + } +} + +/// The kind of one core Wasm export, with a function's arity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WasmExportKind { + /// A function taking `params` values and returning `results` values. + Function { params: usize, results: usize }, + /// A linear memory. + Memory, + /// A table, global or tag. + Other, +} + +/// One export observed in a core Wasm module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WasmExport { + name: String, + kind: WasmExportKind, +} + +#[cfg(test)] +impl WasmExport { + /// Export name as encoded in the module. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Kind of the exported item. + pub(crate) fn kind(&self) -> WasmExportKind { + self.kind + } +} + +/// Facts observed in an accepted core Wasm module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WasmFacts { + sha256: String, + import_modules: Vec, + function_imports: usize, + custom_sections: Vec, + exports: Vec, +} + +/// DWARF sections whose nonempty presence proves a Wasm module or ELF +/// executable was not stripped. +#[cfg(test)] +const REQUIRED_DEBUG_SECTIONS: [&str; 2] = [".debug_info", ".debug_line"]; + +/// Hostcall modules a Fastly program must import to receive the downstream +/// request and send its response. +#[cfg(test)] +const REQUIRED_FASTLY_IMPORT_MODULES: [&str; 2] = ["fastly_http_req", "fastly_http_resp"]; + +#[cfg(test)] +impl WasmFacts { + /// Exports in module order. + pub(crate) fn exports(&self) -> &[WasmExport] { + &self.exports + } + + /// Requires the Fastly Compute WASI command entry ABI. + /// + /// The module must export a `_start` function taking and returning + /// nothing and a `memory`, and import the downstream request and response + /// hostcall modules. Import modules outside the reviewed host ABI were + /// already rejected by [`inspect_wasm`]. + /// + /// # Errors + /// + /// Returns [`Violation::Abi`] naming the first missing or mistyped + /// requirement. + pub(crate) fn require_fastly_entry_abi(&self) -> Result<(), Report> { + let kind = |name: &str| { + self.exports + .iter() + .find(|export| export.name == name) + .map(|export| export.kind) + }; + match kind("_start") { + Some(WasmExportKind::Function { + params: 0, + results: 0, + }) => {} + other => { + return Err(rejected( + Violation::Abi, + format!("export _start must be a nullary function, found {other:?}"), + )); + } + } + if kind("memory") != Some(WasmExportKind::Memory) { + return Err(rejected( + Violation::Abi, + "export memory must be a linear memory", + )); + } + if let Some(missing) = REQUIRED_FASTLY_IMPORT_MODULES + .iter() + .find(|module| !self.import_modules.iter().any(|seen| seen == *module)) + { + return Err(rejected( + Violation::Abi, + format!("missing required import module {missing}"), + )); + } + Ok(()) + } + + /// Custom sections in file order. + pub(crate) fn custom_sections(&self) -> &[WasmCustomSection] { + &self.custom_sections + } + + /// Requires nonempty `.debug_info` and `.debug_line` custom sections. + /// + /// # Errors + /// + /// Returns [`Violation::DebugInfo`] naming the first required section + /// that is absent or has an empty payload in every occurrence. + pub(crate) fn require_debug_sections(&self) -> Result<(), Report> { + for required in REQUIRED_DEBUG_SECTIONS { + let present = self + .custom_sections + .iter() + .any(|section| section.name == required && section.size > 0); + if !present { + return Err(rejected( + Violation::DebugInfo, + format!("missing or empty custom section {required}"), + )); + } + } + Ok(()) + } + + /// Lowercase hex SHA-256 of the inspected bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Distinct import module names, sorted. + pub(crate) fn import_modules(&self) -> &[String] { + &self.import_modules + } + + /// Total number of imported functions. + pub(crate) fn function_imports(&self) -> usize { + self.function_imports + } +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/trusted-server-release/src/inspect/tests.rs b/crates/trusted-server-release/src/inspect/tests.rs new file mode 100644 index 0000000000..57fa6a5f6e --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests.rs @@ -0,0 +1,24 @@ +//! Final-artifact inspection tests over hand-built images. +//! +//! Real-binary observations (dev `ts` builds and the Fastly Wasm) are manual +//! evidence, not tests, because they depend on host build outputs. + +pub(crate) mod builders; +mod wasm; + +use error_stack::Report; + +use super::*; +use crate::error::ReleaseError; + +/// Returns the [`Violation`] attached to an inspection rejection. +fn violation(result: Result>) -> Violation { + let report = result.expect_err("should reject the artifact"); + assert!( + matches!(report.current_context(), ReleaseError::Inspect), + "should report an inspection rejection: {report:?}" + ); + *report + .downcast_ref::() + .expect("should attach the violated rule") +} diff --git a/crates/trusted-server-release/src/inspect/tests/builders.rs b/crates/trusted-server-release/src/inspect/tests/builders.rs new file mode 100644 index 0000000000..ca17e1746f --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/builders.rs @@ -0,0 +1,104 @@ +//! Hand-built Wasm fixtures. + +pub(crate) fn wasm_module(imports: &[(&str, &str)]) -> Vec { + let mut out = b"\0asm\x01\0\0\0".to_vec(); + if imports.is_empty() { + return out; + } + section(&mut out, 1, &[1, 0x60, 0, 0]); + let mut body = vec![u8::try_from(imports.len()).expect("should fit")]; + for (module, name) in imports { + name_bytes(&mut body, module); + name_bytes(&mut body, name); + body.extend_from_slice(&[0, 0]); + } + section(&mut out, 2, &body); + out +} + +/// Appends a section with a single-byte LEB128 length. +pub(super) fn section(out: &mut Vec, id: u8, body: &[u8]) { + out.push(id); + out.push(u8::try_from(body.len()).expect("should fit one LEB128 byte")); + out.extend_from_slice(body); +} + +fn name_bytes(out: &mut Vec, text: &str) { + out.push(u8::try_from(text.len()).expect("should fit one LEB128 byte")); + out.extend_from_slice(text.as_bytes()); +} + +/// Builds a core Wasm module importing `() -> ()` functions, then appending +/// custom sections `(name, payload)` in order. +pub(super) fn wasm_module_with_customs( + imports: &[(&str, &str)], + customs: &[(&str, &[u8])], +) -> Vec { + append_customs(wasm_module(imports), customs) +} + +/// Appends custom sections `(name, payload)` in order to `module`. +pub(crate) fn append_customs(mut module: Vec, customs: &[(&str, &[u8])]) -> Vec { + for (name, payload) in customs { + let mut body = Vec::new(); + name_bytes(&mut body, name); + body.extend_from_slice(payload); + section(&mut module, 0, &body); + } + module +} + +/// The `_start` export shape of a [`command_module`]. +#[derive(Clone, Copy)] +pub(crate) enum Entry { + /// `_start` takes and returns nothing. + Nullary, + /// `_start` takes one `i32`. + TakesI32, + /// The function is exported as `main`, not `_start`. + Renamed, +} + +/// Builds a WASI-command-shaped core module importing `() -> ()` functions, +/// defining one function exported per `entry`, one memory exported as +/// `memory` when `export_memory` is set, and empty bodies. +pub(crate) fn command_module( + imports: &[(&str, &str)], + entry: Entry, + export_memory: bool, +) -> Vec { + let mut out = b"\0asm\x01\0\0\0".to_vec(); + section(&mut out, 1, &[2, 0x60, 0, 0, 0x60, 1, 0x7f, 0]); + if !imports.is_empty() { + let mut body = vec![u8::try_from(imports.len()).expect("should fit")]; + for (module, name) in imports { + name_bytes(&mut body, module); + name_bytes(&mut body, name); + body.extend_from_slice(&[0, 0]); + } + section(&mut out, 2, &body); + } + let entry_type = match entry { + Entry::Nullary | Entry::Renamed => 0, + Entry::TakesI32 => 1, + }; + section(&mut out, 3, &[1, entry_type]); + section(&mut out, 5, &[1, 0, 1]); + let entry_index = u8::try_from(imports.len()).expect("should fit"); + let mut exports = vec![if export_memory { 2 } else { 1 }]; + name_bytes( + &mut exports, + match entry { + Entry::Renamed => "main", + Entry::Nullary | Entry::TakesI32 => "_start", + }, + ); + exports.extend_from_slice(&[0, entry_index]); + if export_memory { + name_bytes(&mut exports, "memory"); + exports.extend_from_slice(&[2, 0]); + } + section(&mut out, 7, &exports); + section(&mut out, 10, &[1, 2, 0, 0x0b]); + out +} diff --git a/crates/trusted-server-release/src/inspect/tests/wasm.rs b/crates/trusted-server-release/src/inspect/tests/wasm.rs new file mode 100644 index 0000000000..23b492aa8a --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/wasm.rs @@ -0,0 +1,332 @@ +//! Core Wasm inspection tests. + +use super::builders::{Entry, command_module, section, wasm_module, wasm_module_with_customs}; +use super::*; + +#[test] +fn valid_core_module_is_accepted_with_observed_imports() { + let bytes = wasm_module(&[ + ("wasi_snapshot_preview1", "fd_write"), + ("fastly_http_req", "send"), + ("fastly_http_req", "body_downstream_get"), + ]); + + let facts = inspect_wasm(&bytes).expect("should accept a Fastly core module"); + + assert_eq!( + facts.import_modules(), + ["fastly_http_req", "wasi_snapshot_preview1"], + "should record distinct sorted import modules" + ); + assert_eq!( + facts.function_imports(), + 3, + "should count every function import" + ); + assert_eq!( + facts.sha256(), + sha256_hex(&bytes), + "should hash the inspected bytes" + ); +} + +#[test] +fn reviewed_fastly_async_io_imports_are_recorded() { + let bytes = wasm_module(&[ + ("fastly_async_io", "is_ready"), + ("fastly_async_io", "select"), + ]); + let facts = inspect_wasm(&bytes).expect("should accept the reviewed asynchronous host module"); + assert_eq!( + facts.import_modules(), + ["fastly_async_io"], + "should retain the exact observed import module" + ); + assert_eq!( + facts.function_imports(), + 2, + "should record both asynchronous host calls" + ); + assert_eq!( + facts.sha256(), + sha256_hex(&bytes), + "should bind the exact inspected bytes" + ); +} + +#[test] +fn import_free_module_is_accepted() { + let facts = inspect_wasm(&wasm_module(&[])).expect("should accept an empty module"); + + assert!( + facts.import_modules().is_empty(), + "should record no imports" + ); +} + +#[test] +fn unknown_import_module_is_rejected() { + for module in [ + "wasi:http/types@0.3.0", + "spin:example/key-value", + "env", + "fastly_example", + "fastly_async_io_extra", + "FASTLY_ASYNC_IO", + "fastly_async_io/v1", + "fastly_backend", + "fastly_abi", + ] { + let bytes = wasm_module(&[("wasi_snapshot_preview1", "fd_write"), (module, "call")]); + + assert_eq!( + violation(inspect_wasm(&bytes)), + Violation::Import, + "should reject {module}" + ); + } +} + +#[test] +fn non_function_import_is_rejected() { + for module in ["fastly_log", "fastly_async_io"] { + let mut bytes = b"\0asm\x01\0\0\0".to_vec(); + let mut body = vec![1]; + for name in [module, "memory"] { + body.push(u8::try_from(name.len()).expect("should fit")); + body.extend_from_slice(name.as_bytes()); + } + body.extend_from_slice(&[2, 0, 1]); + section(&mut bytes, 2, &body); + + assert_eq!( + violation(inspect_wasm(&bytes)), + Violation::Import, + "should reject non-function imports from {module}" + ); + } +} + +#[test] +fn component_is_rejected() { + assert_eq!( + violation(inspect_wasm(b"\0asm\x0d\0\x01\0")), + Violation::Encoding, + "should reject a component before validation" + ); +} + +#[test] +fn non_wasm_is_rejected() { + for bytes in [ + &b""[..], + b"\0asm", + b"\0asm\x02\0\0\0", + b"\x7fELF\x02\x01\x01\0", + ] { + assert_eq!(violation(inspect_wasm(bytes)), Violation::Encoding); + } +} + +#[test] +fn invalid_core_module_is_rejected() { + let mut import_without_type = b"\0asm\x01\0\0\0".to_vec(); + section(&mut import_without_type, 2, &[1, 1, b'a', 1, b'b', 0, 0]); + let mut unknown_section = b"\0asm\x01\0\0\0".to_vec(); + section(&mut unknown_section, 99, &[0]); + + for bytes in [import_without_type, unknown_section] { + assert_eq!(violation(inspect_wasm(&bytes)), Violation::Validation); + } +} + +/// Prefix lengths that end on a section boundary and so form a valid module: +/// the bare header and the header plus the type section. +const SECTION_BOUNDARIES: [usize; 2] = [8, 14]; + +#[test] +fn truncated_modules_are_handled_without_panic() { + let bytes = wasm_module(&[ + ("fastly_log", "write"), + ("wasi_snapshot_preview1", "fd_write"), + ]); + + for length in 0..bytes.len() { + let truncated = &bytes[..length]; + let result = inspect_wasm(truncated); + assert!( + result.is_err() || SECTION_BOUNDARIES.contains(&length), + "should reject the module truncated to {length} bytes" + ); + } +} + +#[test] +fn custom_sections_are_recorded_in_file_order_with_sizes() { + let bytes = wasm_module_with_customs( + &[("fastly_log", "write")], + &[ + (".debug_info", b"info"), + ("name", b"xy"), + (".debug_info", b""), + ], + ); + + let facts = inspect_wasm(&bytes).expect("should accept a module with custom sections"); + + let observed: Vec<(&str, usize)> = facts + .custom_sections() + .iter() + .map(|section| (section.name(), section.size())) + .collect(); + assert_eq!( + observed, + [(".debug_info", 4), ("name", 2), (".debug_info", 0)], + "should record every custom section name and payload size in order" + ); + assert!( + inspect_wasm(&wasm_module(&[])) + .expect("should accept an empty module") + .custom_sections() + .is_empty(), + "should record no custom sections for a bare module" + ); +} + +#[test] +fn debug_sections_are_required_and_nonempty() { + let complete = wasm_module_with_customs( + &[], + &[(".debug_info", b"i"), (".debug_line", b"l"), ("name", b"n")], + ); + inspect_wasm(&complete) + .expect("should accept a module with debug sections") + .require_debug_sections() + .expect("should accept both debug sections"); + + let cases: [(&str, Vec); 4] = [ + ("stripped", wasm_module_with_customs(&[], &[("name", b"n")])), + ( + "missing line", + wasm_module_with_customs(&[], &[(".debug_info", b"i")]), + ), + ( + "missing info", + wasm_module_with_customs(&[], &[(".debug_line", b"l")]), + ), + ( + "empty info", + wasm_module_with_customs(&[], &[(".debug_info", b""), (".debug_line", b"l")]), + ), + ]; + for (label, bytes) in cases { + let facts = inspect_wasm(&bytes).expect("should accept the module itself"); + assert_eq!( + violation(facts.require_debug_sections()), + Violation::DebugInfo, + "should reject a {label} module for the no-strip check" + ); + } +} + +/// Imports of a minimal Fastly command: WASI plus both downstream hostcalls. +const FASTLY_COMMAND_IMPORTS: [(&str, &str); 3] = [ + ("wasi_snapshot_preview1", "fd_write"), + ("fastly_http_req", "body_downstream_get"), + ("fastly_http_resp", "send_downstream"), +]; + +#[test] +fn fastly_command_entry_abi_is_accepted_and_exports_are_recorded() { + let facts = inspect_wasm(&command_module( + &FASTLY_COMMAND_IMPORTS, + Entry::Nullary, + true, + )) + .expect("should accept a Fastly command module"); + + facts + .require_fastly_entry_abi() + .expect("should accept the Fastly entry ABI"); + let observed: Vec<(&str, WasmExportKind)> = facts + .exports() + .iter() + .map(|export| (export.name(), export.kind())) + .collect(); + assert_eq!( + observed, + [ + ( + "_start", + WasmExportKind::Function { + params: 0, + results: 0 + } + ), + ("memory", WasmExportKind::Memory), + ], + "should record every export with its kind in module order" + ); +} + +#[test] +fn wrong_fastly_entry_abi_is_rejected() { + let only_wasi = [("wasi_snapshot_preview1", "fd_write")]; + let without_response = [ + ("wasi_snapshot_preview1", "fd_write"), + ("fastly_http_req", "body_downstream_get"), + ]; + let cases: [(&str, Vec); 6] = [ + ( + "missing _start", + command_module(&FASTLY_COMMAND_IMPORTS, Entry::Renamed, true), + ), + ( + "_start taking an argument", + command_module(&FASTLY_COMMAND_IMPORTS, Entry::TakesI32, true), + ), + ( + "unexported memory", + command_module(&FASTLY_COMMAND_IMPORTS, Entry::Nullary, false), + ), + ( + "plain WASI command", + command_module(&only_wasi, Entry::Nullary, true), + ), + ( + "missing response hostcalls", + command_module(&without_response, Entry::Nullary, true), + ), + ( + "library without exports", + wasm_module(&FASTLY_COMMAND_IMPORTS), + ), + ]; + for (label, bytes) in cases { + let facts = inspect_wasm(&bytes).expect("should accept the module itself"); + assert_eq!( + violation(facts.require_fastly_entry_abi()), + Violation::Abi, + "should reject a {label} as the Fastly entry ABI" + ); + } +} + +#[test] +fn foreign_host_abi_command_is_rejected_before_the_entry_check() { + let bytes = command_module( + &[ + ("fastly_http_req", "body_downstream_get"), + ("fastly_http_resp", "send_downstream"), + ("wasi:http/types@0.3.0", "handle"), + ], + Entry::Nullary, + true, + ); + + assert_eq!( + violation(inspect_wasm(&bytes)), + Violation::Import, + "should reject a command importing a foreign host ABI" + ); +} diff --git a/crates/trusted-server-release/src/inspect/wasm.rs b/crates/trusted-server-release/src/inspect/wasm.rs new file mode 100644 index 0000000000..b28c0efb67 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/wasm.rs @@ -0,0 +1,156 @@ +//! Core WebAssembly inspection for the Fastly Compute artifact. + +use std::collections::BTreeSet; + +use error_stack::Report; +use wasmparser::types::{EntityType, Types}; +use wasmparser::{CompositeInnerType, Parser, Payload, TypeRef, Validator, WasmFeatures}; + +use super::{ + Violation, WasmCustomSection, WasmExport, WasmExportKind, WasmFacts, rejected, sha256_hex, +}; +use crate::error::ReleaseError; + +/// Reviewed import modules of the Fastly Compute host ABI. +/// +/// Observed in release builds of `trusted-server-adapter-fastly`: WASI +/// preview 1 plus these `fastly_*` hostcall modules. The locked SDK 0.13.1 +/// also uses the reviewed asynchronous I/O module supported by Viceroy 0.21.0. +/// Adding a module changes the host ABI policy and requires review. +pub(crate) const FASTLY_IMPORT_MODULES: &[&str] = &[ + "fastly_async_io", + "fastly_cache", + "fastly_compute_runtime", + "fastly_config_store", + "fastly_erl", + "fastly_geo", + "fastly_http_body", + "fastly_http_cache", + "fastly_http_downstream", + "fastly_http_req", + "fastly_http_resp", + "fastly_image_optimizer", + "fastly_kv_store", + "fastly_log", + "fastly_purge", + "fastly_secret_store", + "wasi_snapshot_preview1", +]; + +/// Pinned validation feature set: the WebAssembly 2.0 specification. +const FEATURES: WasmFeatures = WasmFeatures::WASM2; + +/// Inspects a final Fastly Compute Wasm artifact. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Inspect`] with a [`Violation`] when the bytes are +/// a component or not a core module ([`Violation::Encoding`]), fail +/// validation under [`WasmFeatures::WASM2`] ([`Violation::Validation`]), +/// or import a non-function item or from a module outside +/// [`FASTLY_IMPORT_MODULES`] ([`Violation::Import`]). +pub(crate) fn inspect_wasm(bytes: &[u8]) -> Result> { + if Parser::is_component(bytes) { + return Err(rejected( + Violation::Encoding, + "component, not a core module", + )); + } + if !Parser::is_core_wasm(bytes) { + return Err(rejected(Violation::Encoding, "missing core module header")); + } + let types = Validator::new_with_features(FEATURES) + .validate_all(bytes) + .map_err(|error| rejected(Violation::Validation, error))?; + let exports = exports(&types)?; + let (import_modules, function_imports) = imports(bytes)?; + if let Some(module) = import_modules + .iter() + .find(|module| !FASTLY_IMPORT_MODULES.contains(&module.as_str())) + { + return Err(rejected( + Violation::Import, + format!("import module {module:?}"), + )); + } + Ok(WasmFacts { + sha256: sha256_hex(bytes), + import_modules: import_modules.into_iter().collect(), + function_imports, + custom_sections: custom_sections(bytes)?, + exports, + }) +} + +/// Returns every export of the validated module in module order. +fn exports(types: &Types) -> Result, Report> { + let types = types.as_ref(); + let Some(exports) = types.core_exports() else { + return Err(rejected( + Violation::Encoding, + "validated types describe a component", + )); + }; + Ok(exports + .map(|(name, entity)| { + let kind = match entity { + EntityType::Func(id) | EntityType::FuncExact(id) => { + match &types[id].composite_type.inner { + CompositeInnerType::Func(function) => WasmExportKind::Function { + params: function.params().len(), + results: function.results().len(), + }, + _ => WasmExportKind::Other, + } + } + EntityType::Memory(_) => WasmExportKind::Memory, + EntityType::Table(_) | EntityType::Global(_) | EntityType::Tag(_) => { + WasmExportKind::Other + } + }; + WasmExport { + name: name.to_owned(), + kind, + } + }) + .collect()) +} + +/// Returns every custom section in file order. +fn custom_sections(bytes: &[u8]) -> Result, Report> { + let mut sections = Vec::new(); + for payload in Parser::new(0).parse_all(bytes) { + let payload = payload.map_err(|error| rejected(Violation::Validation, error))?; + if let Payload::CustomSection(reader) = payload { + sections.push(WasmCustomSection { + name: reader.name().to_owned(), + size: reader.data().len(), + }); + } + } + Ok(sections) +} + +/// Returns the distinct import modules and the function import count. +fn imports(bytes: &[u8]) -> Result<(BTreeSet, usize), Report> { + let parse_error = |error| rejected(Violation::Validation, error); + let mut modules = BTreeSet::new(); + let mut functions = 0; + for payload in Parser::new(0).parse_all(bytes) { + let Payload::ImportSection(reader) = payload.map_err(parse_error)? else { + continue; + }; + for import in reader.into_imports() { + let import = import.map_err(parse_error)?; + if !matches!(import.ty, TypeRef::Func(_)) { + return Err(rejected( + Violation::Import, + format!("non-function import {}::{}", import.module, import.name), + )); + } + modules.insert(import.module.to_owned()); + functions += 1; + } + } + Ok((modules, functions)) +} diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs new file mode 100644 index 0000000000..5035dfd708 --- /dev/null +++ b/crates/trusted-server-release/src/main.rs @@ -0,0 +1,186 @@ +//! Ordinary release measurement, equality and Fastly package inspection. + +mod documents; +mod error; +mod inspect; +mod ordinary; +mod package; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "bounded process APIs are exercised by safety tests" + ) +)] +mod process; + +use std::ffi::OsString; +use std::io::Write as _; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use error::ReleaseError; +use error_stack::Report; +use error_stack::fmt::ColorMode; + +#[derive(Parser)] +#[command( + name = "trusted-server-release", + disable_help_flag = true, + disable_help_subcommand = true, + disable_version_flag = true +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Package the exact Wasm with the fixed service-neutral release manifest. + PackFastlyPackage { + #[arg(long)] + wasm: PathBuf, + #[arg(long)] + output: PathBuf, + }, + /// Seal fixed target-scoped remapping arguments before an ordinary build. + OrdinaryRustflags { + #[arg(long)] + checkout: PathBuf, + #[arg(long)] + target: PathBuf, + #[arg(long)] + role: String, + #[arg(long)] + output: PathBuf, + }, + /// Record one actual ordinary Fastly build; grants no recipe qualification. + OrdinaryMeasure { + #[arg(long)] + request: PathBuf, + #[arg(long)] + output: PathBuf, + }, + /// Validate current-run upload bindings before downloading exact artifact IDs. + OrdinaryTransferPlan { + #[arg(long)] + input: PathBuf, + }, + /// Compare the four bound archives and seal only actual ordinary equality. + OrdinaryCompare { + #[arg(long)] + input: PathBuf, + #[arg(long)] + archives: PathBuf, + #[arg(long)] + source: PathBuf, + #[arg(long)] + output: PathBuf, + }, + /// Inspect a retained manual Fastly package against independently approved digests. + InspectFastlyPackage { + #[arg(long)] + package_dir: PathBuf, + #[arg(long)] + manifest: PathBuf, + #[arg(long)] + expected_manifest_sha256: String, + #[arg(long)] + expected_wasm_sha256: String, + }, +} + +/// Writes one human-readable diagnostic line to stderr. +/// +/// The `log` facade is deliberately not used: no logger is installed before +/// the environment audit runs, and configuring one from inherited variables +/// such as `RUST_LOG` would itself be an unaudited input channel. Write +/// failures are ignored because diagnostics never change the outcome. +pub(crate) fn notice(message: std::fmt::Arguments<'_>) { + let _ = writeln!(std::io::stderr(), "{message}"); +} + +fn main() -> ExitCode { + let inherited: Vec<(OsString, OsString)> = std::env::vars_os().collect(); + Report::set_color_mode(ColorMode::None); + let arguments: Vec = std::env::args_os().collect(); + // Restore waitable children before any thread or child exists; an + // ignoring launcher would otherwise let the kernel reap supervised leaders. + match process::establish_waitable_children().and_then(|()| run(&inherited, &arguments)) { + Ok(status) => status, + Err(report) => { + notice(format_args!("{report:?}")); + ExitCode::FAILURE + } + } +} + +fn run( + inherited: &[(OsString, OsString)], + arguments: &[OsString], +) -> Result> { + let cli = Cli::try_parse_from(arguments).map_err(|error| { + Report::new(ReleaseError::Usage).attach(format!("invalid invocation: {}", error.kind())) + })?; + match cli.command { + Command::PackFastlyPackage { wasm, output } => { + package::build_package(&wasm, &output)?; + Ok(ExitCode::SUCCESS) + } + Command::OrdinaryRustflags { + checkout, + target, + role, + output, + } => { + let argument = + ordinary::prepare_rustflags(&checkout, &target, &role, &output, inherited)?; + writeln!(std::io::stdout(), "{argument}").map_err(|_| { + Report::new(ReleaseError::Document).attach("cannot write ordinary Cargo argument") + })?; + Ok(ExitCode::SUCCESS) + } + Command::OrdinaryMeasure { request, output } => { + ordinary::measure_build(&request, &output)?; + Ok(ExitCode::SUCCESS) + } + Command::OrdinaryTransferPlan { input } => { + let value = ordinary::transfer_plan(&input)?; + let bytes = documents::canonical_json(&value, documents::Role::Metadata)?; + std::io::stdout().write_all(&bytes).map_err(|_| { + Report::new(ReleaseError::Document).attach("cannot write ordinary transfer plan") + })?; + Ok(ExitCode::SUCCESS) + } + Command::OrdinaryCompare { + input, + archives, + source, + output, + } => { + ordinary::compare_transfers(&input, &archives, &source, &output)?; + Ok(ExitCode::SUCCESS) + } + Command::InspectFastlyPackage { + package_dir, + manifest, + expected_manifest_sha256, + expected_wasm_sha256, + } => { + let report = package::inspect_package( + &package_dir, + &manifest, + &expected_manifest_sha256, + &expected_wasm_sha256, + )?; + let bytes = documents::canonical_json(&report, documents::Role::Metadata)?; + std::io::stdout().write_all(&bytes).map_err(|error| { + Report::new(ReleaseError::Inspect) + .attach(format!("cannot write package inspection: {error}")) + })?; + Ok(ExitCode::SUCCESS) + } + } +} diff --git a/crates/trusted-server-release/src/ordinary.rs b/crates/trusted-server-release/src/ordinary.rs new file mode 100644 index 0000000000..b10386b27c --- /dev/null +++ b/crates/trusted-server-release/src/ordinary.rs @@ -0,0 +1,387 @@ +//! Informational ordinary-release observations; these confer no qualification. +//! +//! Parsing and pairing establish neither source eligibility nor tool provenance. +//! The recorder checks tool version strings against the source pins and holds +//! root identities only while it measures; it does not prove which compiler +//! Cargo invoked. The transfer join re-checks version pins against the +//! comparison checkout; observation roots are only checked lexically here. + +use error_stack::Report; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; +use std::path::{Component, Path}; + +use crate::documents::{self, Role}; +use crate::error::ReleaseError; + +mod archive; +mod cargo; +mod commands; +mod diagnostic; +mod inputs; +mod normalization; +mod output; +mod run; +mod transfer; + +pub(crate) use commands::{compare_transfers, measure_build, prepare_rustflags, transfer_plan}; + +const PROFILE: &str = "ordinary-release-fastly-byte-equality-v1"; +const ASSET: &str = "trusted-server-fastly.wasm"; +const PACKAGE: &str = "trusted-server-fastly.tar.gz"; +const GAPS: [&str; 4] = [ + "alternate-user-unqualified", + "boundary-unqualified", + "materials-unqualified", + "tools-unqualified", +]; + +/// Validated ordinary observations, without controlled-recipe authority. +pub(crate) struct Observation { + value: Value, + facts: Facts, +} + +impl Observation { + /// The original strict JSON facts retained for informational serialization. + pub(crate) fn value(&self) -> &Value { + &self.value + } +} + +/// Final-byte equality of an ordinary pair, without controlled-recipe authority. +pub(crate) struct Equality { + canonical: Observation, + reproduction: Observation, + sha256: String, + size: u64, +} + +impl Equality { + /// SHA-256 of the complete equal final bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Size of the complete equal final bytes. + pub(crate) fn size(&self) -> u64 { + self.size + } +} + +/// Compares complete ordinary final bytes after checking their input pairing. +pub(crate) fn compare( + canonical: Observation, + reproduction: Observation, + left: &[u8], + right: &[u8], +) -> Result> { + pair(&canonical, &reproduction).map_err(|report| { + diagnostic::tag( + report.change_context(ReleaseError::RebuildIdentity), + diagnostic::Code::IdentityMismatch, + ) + })?; + let mut digests = Vec::new(); + for (observed, bytes) in [(&canonical, left), (&reproduction, right)] { + let size = u64::try_from(bytes.len()) + .map_err(|_| Report::new(ReleaseError::Document).attach("ordinary size overflow"))?; + require_as( + ReleaseError::RebuildMismatch, + size > 0 && size <= Role::Executable.limit() && size == observed.facts.artifact.size, + "ordinary executable changed after observation: size or byte bound rejected", + ) + .map_err(|report| { + report.attach( + diagnostic::Failure::new(diagnostic::Code::SizeMismatch).binaries(left, right), + ) + })?; + let digest = hex::encode(Sha256::digest(bytes)); + require_as( + ReleaseError::RebuildMismatch, + digest == observed.facts.artifact.sha256, + "ordinary executable changed after observation", + ) + .map_err(|report| { + report.attach( + diagnostic::Failure::new(diagnostic::Code::DigestMismatch).binaries(left, right), + ) + })?; + digests.push(digest); + } + if left != right { + let offset = left + .iter() + .zip(right) + .position(|(left, right)| left != right) + .unwrap_or(left.len().min(right.len())); + let window = |bytes: &[u8]| { + let start = offset.saturating_sub(8).min(bytes.len()); + let end = start.saturating_add(16).min(bytes.len()); + hex::encode(&bytes[start..end]) + }; + return Err(Report::new(ReleaseError::RebuildMismatch) + .attach(format!( + "ordinary final-byte mismatch: canonical_size={}, reproduction_size={}, \ + canonical_sha256={}, reproduction_sha256={}, first_offset={}, \ + canonical_window={}, reproduction_window={}", + left.len(), + right.len(), + digests[0], + digests[1], + offset, + window(left), + window(right), + )) + .attach(diagnostic::Failure::mismatch(left, right, offset))); + } + Ok(Equality { + sha256: canonical.facts.artifact.sha256.clone(), + size: canonical.facts.artifact.size, + canonical, + reproduction, + }) +} + +/// Checks the narrower ordinary input/environment pairing, never qualification. +pub(crate) fn pair( + canonical: &Observation, + reproduction: &Observation, +) -> Result<(), Report> { + let canonical = &canonical.facts; + let reproduction = &reproduction.facts; + require( + canonical.role == "canonical" && reproduction.role == "reproduction", + "ordinary comparison requires canonical then reproduction", + )?; + require( + canonical.source_commit == reproduction.source_commit + && canonical.recipe_sha256 == reproduction.recipe_sha256 + && canonical.cargo_lock_sha256 == reproduction.cargo_lock_sha256 + && canonical.npm_lock_sha256 == reproduction.npm_lock_sha256 + && canonical.js_inventory_sha256 == reproduction.js_inventory_sha256 + && canonical.tools == reproduction.tools, + "ordinary build inputs differ", + )?; + let canonical_environment = &canonical.environment; + let reproduction_environment = &reproduction.environment; + require( + canonical_environment.started_at_seconds != reproduction_environment.started_at_seconds + && canonical_environment.umask == 0o022 + && reproduction_environment.umask == 0o027 + && canonical_environment.jobs == 4 + && reproduction_environment.jobs == 2 + && canonical_environment.uid == reproduction_environment.uid, + "ordinary environment pairing rejected", + )?; + for left in [ + &canonical_environment.checkout_root, + &canonical_environment.target_root, + &canonical_environment.cargo_home, + ] { + for right in [ + &reproduction_environment.checkout_root, + &reproduction_environment.target_root, + &reproduction_environment.cargo_home, + ] { + require( + !Path::new(left).starts_with(right) && !Path::new(right).starts_with(left), + "ordinary build roots overlap", + )?; + } + } + let mut arguments = Vec::new(); + for facts in [canonical, reproduction] { + let flags = normalization::plan( + Path::new(&facts.environment.checkout_root), + Path::new(&facts.environment.target_root), + &facts.role, + )?; + require( + flags.sha256 == facts.rustflags_sha256, + "ordinary retained rustflags disagree with derived physical roots", + )?; + arguments.push(flags.arguments); + } + require( + arguments[0] == arguments[1], + "ordinary builds used different raw remap arguments", + )?; + + Ok(()) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Facts { + version: u8, + profile: String, + release_qualification: bool, + source_commit: String, + asset: String, + target: String, + variant: String, + role: String, + recipe_sha256: String, + cargo_lock_sha256: String, + npm_lock_sha256: String, + js_inventory_sha256: String, + rustflags_sha256: String, + tools: Tools, + environment: Environment, + artifact: Artifact, + package: Artifact, + gaps: Vec, +} + +#[derive(Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Tool { + version: String, + sha256: String, +} + +#[derive(Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Tools { + rustc: Tool, + node: Tool, + acquisition: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Environment { + checkout_root: String, + target_root: String, + cargo_home: String, + started_at_seconds: u64, + umask: u32, + jobs: u32, + locale: String, + timezone: String, + uid: u32, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Artifact { + size: u64, + sha256: String, +} + +fn require_as( + error: ReleaseError, + condition: bool, + message: &'static str, +) -> Result<(), Report> { + if !condition { + return Err(Report::new(error).attach(message)); + } + Ok(()) +} + +fn require(condition: bool, message: &'static str) -> Result<(), Report> { + require_as(ReleaseError::Document, condition, message) +} + +fn digest(text: &str, length: usize) -> bool { + text.len() == length + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn root(text: &str) -> bool { + let path = Path::new(text); + text.len() <= 4096 + && path.is_absolute() + && path.components().count() > 1 + && path + .components() + .all(|part| matches!(part, Component::RootDir | Component::Normal(_))) + && !text.chars().any(char::is_control) + && !text.contains("//") + && !text.ends_with('/') + && !text.split('/').any(|part| part == "." || part == "..") +} + +/// Parses one ordinary Fastly observation without granting execution authority. +pub(crate) fn observation(bytes: &[u8]) -> Result> { + let value = documents::parse_json(bytes, Role::Metadata).map_err(|_| { + Report::new(ReleaseError::Document).attach("ordinary observation JSON rejected") + })?; + let facts: Facts = serde_json::from_value(value.clone()).map_err(|_| { + Report::new(ReleaseError::Document).attach("ordinary observation fields or types rejected") + })?; + require( + facts.version == 1 + && facts.profile == PROFILE + && !facts.release_qualification + && facts.asset == ASSET + && facts.target == "wasm32-wasip1" + && facts.variant == "default" + && matches!(facts.role.as_str(), "canonical" | "reproduction"), + "ordinary identity or qualification claim rejected", + )?; + require( + digest(&facts.source_commit, 40) + && [ + &facts.recipe_sha256, + &facts.cargo_lock_sha256, + &facts.npm_lock_sha256, + &facts.js_inventory_sha256, + &facts.rustflags_sha256, + &facts.artifact.sha256, + &facts.package.sha256, + &facts.tools.rustc.sha256, + &facts.tools.node.sha256, + ] + .into_iter() + .all(|text| digest(text, 64)), + "ordinary digest rejected", + )?; + require( + facts.tools.acquisition == "ordinary-hosted-toolchain" + && [&facts.tools.rustc, &facts.tools.node] + .into_iter() + .all(|tool| { + !tool.version.is_empty() + && tool.version.len() <= 512 + && !tool.version.chars().any(char::is_control) + }), + "ordinary tool observations rejected", + )?; + let environment = &facts.environment; + require( + root(&environment.checkout_root) + && root(&environment.target_root) + && !Path::new(&environment.checkout_root).starts_with(&environment.target_root) + && !Path::new(&environment.target_root).starts_with(&environment.checkout_root) + && environment.started_at_seconds > 0 + && environment.umask <= 0o777 + && (1..=256).contains(&environment.jobs) + && environment.locale == "C" + && environment.timezone == "UTC" + && environment.uid > 0, + "ordinary environment observations rejected", + )?; + require( + normalization::cargo_home(Path::new(&environment.target_root), &facts.role) + .is_some_and(|home| home == Path::new(&environment.cargo_home)), + "ordinary Cargo home is not the role's private home", + )?; + require( + facts.artifact.size > 0 + && facts.artifact.size <= Role::Executable.limit() + && facts.package.size > 0 + && facts.package.size <= Role::ArtifactArchive.limit(), + "ordinary artifact size rejected", + )?; + require(facts.gaps == GAPS, "ordinary assurance gaps rejected")?; + Ok(Observation { value, facts }) +} + +#[cfg(test)] +pub(super) mod tests; diff --git a/crates/trusted-server-release/src/ordinary/archive.rs b/crates/trusted-server-release/src/ordinary/archive.rs new file mode 100644 index 0000000000..afe051ae03 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/archive.rs @@ -0,0 +1,381 @@ +//! Deliberately narrow ZIP32 profile: flat regular files, stored or deflated. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use flate2::{Crc, Decompress, FlushDecompress, Status}; + +use crate::documents::Role; + +const ARCHIVE_LIMIT: usize = 768 * 1024 * 1024; + +fn collection_error(detail: impl core::fmt::Display) -> Report { + Report::new(ReleaseError::Document).attach(detail.to_string()) +} + +use crate::error::ReleaseError; + +const MEMBER_LIMIT: u64 = 512 * 1024 * 1024; +const TOTAL_LIMIT: u64 = 768 * 1024 * 1024; +const MEMBER_COUNT: usize = 32; + +type Result = std::result::Result>; + +fn slice(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8]> { + let end = offset + .checked_add(length) + .ok_or_else(|| collection_error("ZIP offset overflow"))?; + bytes + .get(offset..end) + .ok_or_else(|| collection_error("truncated ZIP")) +} +fn u16_at(bytes: &[u8], offset: usize) -> Result { + Ok(u16::from_le_bytes( + slice(bytes, offset, 2)? + .try_into() + .map_err(collection_error)?, + )) +} +fn u32_at(bytes: &[u8], offset: usize) -> Result { + Ok(u32::from_le_bytes( + slice(bytes, offset, 4)? + .try_into() + .map_err(collection_error)?, + )) +} +fn require(condition: bool, detail: &str) -> Result<()> { + if !condition { + return Err(collection_error(detail)); + } + Ok(()) +} + +struct Entry { + name: String, + flags: u16, + method: u16, + crc: u32, + compressed: usize, + expanded: usize, + offset: usize, +} + +fn central( + bytes: &[u8], + cursor: usize, + declared: &BTreeMap, +) -> Result<(Entry, usize)> { + require( + u32_at(bytes, cursor)? == 0x02014b50, + "missing central ZIP member", + )?; + let flags = u16_at(bytes, cursor + 8)?; + let method = u16_at(bytes, cursor + 10)?; + let name_length = usize::from(u16_at(bytes, cursor + 28)?); + let extra = usize::from(u16_at(bytes, cursor + 30)?); + let comment = usize::from(u16_at(bytes, cursor + 32)?); + require( + flags & !0x0808 == 0 && matches!(method, 0 | 8), + "unsupported ZIP flags or compression", + )?; + require( + u16_at(bytes, cursor + 6)? <= 20 + && extra == 0 + && comment == 0 + && u16_at(bytes, cursor + 34)? == 0, + "ZIP64, extras, comments or multidisk ZIP rejected", + )?; + let mode = u32_at(bytes, cursor + 38)? >> 16; + require( + mode & 0o170000 == 0 || mode & 0o170000 == 0o100000, + "ZIP member is not a regular file", + )?; + require( + u32_at(bytes, cursor + 38)? & 0x10 == 0, + "ZIP directory rejected", + )?; + let name = std::str::from_utf8(slice(bytes, cursor + 46, name_length)?) + .map_err(collection_error)? + .to_owned(); + safe_name(&name).map_err(|error| error.change_context(ReleaseError::Document))?; + let expanded = u32_at(bytes, cursor + 24)?; + let compressed = u32_at(bytes, cursor + 20)?; + require( + expanded != u32::MAX + && compressed != u32::MAX + && u64::from(expanded) <= MEMBER_LIMIT + && u64::from(compressed) <= MEMBER_LIMIT, + "ZIP member limit exceeded", + )?; + require( + declared.get(&name) == Some(&u64::from(expanded)), + "undeclared ZIP member or changed declared length", + )?; + Ok(( + Entry { + name, + flags, + method, + crc: u32_at(bytes, cursor + 16)?, + compressed: compressed as usize, + expanded: expanded as usize, + offset: u32_at(bytes, cursor + 42)? as usize, + }, + cursor + 46 + name_length, + )) +} + +fn local(bytes: &[u8], entry: &Entry, central_start: usize) -> Result<(usize, Vec)> { + let start = entry.offset; + require( + u32_at(bytes, start)? == 0x04034b50, + "missing local ZIP member", + )?; + require( + u16_at(bytes, start + 4)? <= 20 + && u16_at(bytes, start + 6)? == entry.flags + && u16_at(bytes, start + 8)? == entry.method, + "local ZIP profile disagrees with central entry", + )?; + let name_length = usize::from(u16_at(bytes, start + 26)?); + require(u16_at(bytes, start + 28)? == 0, "local ZIP extras rejected")?; + require( + slice(bytes, start + 30, name_length)? == entry.name.as_bytes(), + "local ZIP name disagrees", + )?; + let descriptor = entry.flags & 8 != 0; + for (offset, value) in [ + (14, entry.crc), + (18, entry.compressed as u32), + (22, entry.expanded as u32), + ] { + let local = u32_at(bytes, start + offset)?; + require( + local == value || (descriptor && local == 0), + "local ZIP sizes or CRC disagree", + )?; + } + let body_start = start + 30 + name_length; + let compressed = slice(bytes, body_start, entry.compressed)?; + let mut end = body_start + entry.compressed; + if descriptor { + if u32_at(bytes, end)? == 0x08074b50 { + end += 4; + } + require( + u32_at(bytes, end)? == entry.crc + && u32_at(bytes, end + 4)? == entry.compressed as u32 + && u32_at(bytes, end + 8)? == entry.expanded as u32, + "ZIP data descriptor disagrees", + )?; + end += 12; + } + require( + end <= central_start, + "ZIP member overlaps central directory", + )?; + let body = if entry.method == 0 { + require( + entry.compressed == entry.expanded, + "stored ZIP sizes disagree", + )?; + compressed.to_vec() + } else { + inflate(compressed, entry.expanded)? + }; + let mut crc = Crc::new(); + crc.update(&body); + require( + body.len() == entry.expanded && crc.sum() == entry.crc, + "ZIP member CRC mismatch", + )?; + Ok((end, body)) +} + +fn read(bytes: &[u8], declared: &BTreeMap) -> Result>> { + require( + bytes.len() <= ARCHIVE_LIMIT + && bytes.len() >= 22 + && !declared.is_empty() + && declared.len() <= MEMBER_COUNT, + "ZIP archive/count limit exceeded", + )?; + let mut total = 0_u64; + let mut names = BTreeSet::new(); + for (name, size) in declared { + safe_name(name).map_err(|error| error.change_context(ReleaseError::Document))?; + total = total + .checked_add(*size) + .ok_or_else(|| collection_error("ZIP expanded limit overflow"))?; + require( + *size <= MEMBER_LIMIT + && total <= TOTAL_LIMIT + && names.insert(name.to_ascii_lowercase()), + "ZIP declared size or duplicate name rejected", + )?; + } + let end = bytes.len() - 22; + require( + u32_at(bytes, end)? == 0x06054b50 && u16_at(bytes, end + 20)? == 0, + "ZIP end record or trailing data rejected", + )?; + let count = usize::from(u16_at(bytes, end + 10)?); + require( + u16_at(bytes, end + 4)? == 0 + && u16_at(bytes, end + 6)? == 0 + && usize::from(u16_at(bytes, end + 8)?) == count + && count == declared.len(), + "ZIP disk/count mismatch", + )?; + let central_start = u32_at(bytes, end + 16)? as usize; + let central_size = u32_at(bytes, end + 12)? as usize; + require( + central_start.checked_add(central_size) == Some(end), + "ZIP central bounds mismatch", + )?; + let mut cursor = central_start; + let mut entries = Vec::new(); + let mut seen = BTreeSet::new(); + for _ in 0..count { + let (entry, next) = central(bytes, cursor, declared)?; + require( + seen.insert(entry.name.to_ascii_lowercase()), + "duplicate ZIP member", + )?; + entries.push(entry); + cursor = next; + } + require(cursor == end, "hidden central ZIP members rejected")?; + entries.sort_by_key(|entry| entry.offset); + let mut expected_offset = 0; + let mut members = BTreeMap::new(); + for entry in entries { + require( + entry.offset == expected_offset, + "overlapping or hidden local ZIP members rejected", + )?; + let (next, body) = local(bytes, &entry, central_start)?; + members.insert(entry.name, body); + expected_offset = next; + } + require( + expected_offset == central_start, + "hidden data before central ZIP rejected", + )?; + Ok(members) +} + +fn safe_name(name: &str) -> Result<()> { + require( + !name.is_empty() + && name.len() <= 160 + && name.as_bytes()[0].is_ascii_alphanumeric() + && name.split('.').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }), + "unsafe flat ZIP member name", + ) +} + +// Exhausted input is not a terminal DEFLATE marker, even with matching output and CRC. +fn inflate(compressed: &[u8], expanded: usize) -> Result> { + let mut decoder = Decompress::new(false); + let mut body = Vec::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let before_in = decoder.total_in(); + let before_out = decoder.total_out(); + let offset = usize::try_from(before_in).map_err(collection_error)?; + let input = compressed + .get(offset..) + .ok_or_else(|| collection_error("deflated ZIP consumed beyond its member"))?; + let remaining = expanded - body.len(); + let limit = buffer.len().min(remaining + 1); + let status = decoder + .decompress(input, &mut buffer[..limit], FlushDecompress::None) + .map_err(collection_error)?; + let written = + usize::try_from(decoder.total_out() - before_out).map_err(collection_error)?; + require( + written <= remaining, + "deflated ZIP exceeds declared expanded size", + )?; + body.extend_from_slice(&buffer[..written]); + if status == Status::StreamEnd { + require( + decoder.total_in() == compressed.len() as u64 + && decoder.total_out() == expanded as u64, + "deflated ZIP length or compressed consumption mismatch", + )?; + return Ok(body); + } + require( + decoder.total_in() != before_in || decoder.total_out() != before_out, + "deflated ZIP did not reach its final stream marker", + )?; + } +} + +/// Source-owned fixed member sets for the ordinary transfer. +#[derive(Clone, Copy)] +pub(super) enum Profile { + Build, + Observation, +} + +/// Reads only the fixed flat regular members, under source-owned limits. +/// +/// Directory lengths are untrusted allocation inputs, not observed build facts. +/// The carried reader still verifies every local/central/descriptor/CRC join; +/// the caller separately checks the authenticated archive and artifact digests. +pub(super) fn contents(bytes: &[u8], profile: Profile) -> Result>> { + let limits = match profile { + Profile::Build => BTreeMap::from([ + (super::ASSET.to_owned(), Role::Executable.limit()), + (super::PACKAGE.to_owned(), Role::ArtifactArchive.limit()), + (format!("{}.spdx.json", super::ASSET), Role::Spdx.limit()), + ]), + Profile::Observation => { + BTreeMap::from([("fastly-observation.json".to_owned(), Role::Metadata.limit())]) + } + }; + require( + bytes.len() >= 22 && bytes.len() <= ARCHIVE_LIMIT, + "ordinary ZIP archive size rejected", + )?; + let end = bytes.len() - 22; + require( + u32_at(bytes, end)? == 0x06054b50 && usize::from(u16_at(bytes, end + 10)?) == limits.len(), + "ordinary ZIP exact member count rejected", + )?; + let mut cursor = u32_at(bytes, end + 16)? as usize; + let mut declared = BTreeMap::new(); + for _ in 0..limits.len() { + require( + u32_at(bytes, cursor)? == 0x02014b50, + "ordinary ZIP central record rejected", + )?; + let length = usize::from(u16_at(bytes, cursor + 28)?); + let extra = usize::from(u16_at(bytes, cursor + 30)?); + let comment = usize::from(u16_at(bytes, cursor + 32)?); + let name = + std::str::from_utf8(slice(bytes, cursor + 46, length)?).map_err(collection_error)?; + let expanded = u64::from(u32_at(bytes, cursor + 24)?); + require( + limits + .get(name) + .is_some_and(|limit| expanded > 0 && expanded <= *limit) + && declared.insert(name.to_owned(), expanded).is_none(), + "ordinary ZIP member identity or size rejected", + )?; + cursor += 46 + length + extra + comment; + } + require(cursor == end, "ordinary ZIP central length rejected")?; + read(bytes, &declared) +} + +#[cfg(test)] +pub(super) mod tests; diff --git a/crates/trusted-server-release/src/ordinary/archive/tests.rs b/crates/trusted-server-release/src/ordinary/archive/tests.rs new file mode 100644 index 0000000000..37f2f8a87e --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/archive/tests.rs @@ -0,0 +1,372 @@ +use std::collections::BTreeMap; +use std::io::Write as _; + +use flate2::Crc; + +use super::{Profile, contents}; + +pub(in crate::ordinary) fn zip(members: &[(&str, &[u8])], descriptor: bool) -> Vec { + let mut bytes = Vec::new(); + let mut central = Vec::new(); + for (name, body) in members { + let offset = bytes.len() as u32; + let mut crc = Crc::new(); + crc.update(body); + let crc = crc.sum(); + let size = body.len() as u32; + let flags = if descriptor { 8_u16 } else { 0 }; + bytes.extend(0x04034b50_u32.to_le_bytes()); + bytes.extend(20_u16.to_le_bytes()); + bytes.extend(flags.to_le_bytes()); + bytes.extend([0; 6]); + for field in if descriptor { + [0, 0, 0] + } else { + [crc, size, size] + } { + bytes.extend(field.to_le_bytes()); + } + bytes.extend((name.len() as u16).to_le_bytes()); + bytes.extend(0_u16.to_le_bytes()); + bytes.extend(name.as_bytes()); + bytes.extend(*body); + if descriptor { + for field in [0x08074b50, crc, size, size] { + bytes.extend(field.to_le_bytes()); + } + } + central.extend(0x02014b50_u32.to_le_bytes()); + central.extend(20_u16.to_le_bytes()); + central.extend(20_u16.to_le_bytes()); + central.extend(flags.to_le_bytes()); + central.extend([0; 6]); + for field in [crc, size, size] { + central.extend(field.to_le_bytes()); + } + central.extend((name.len() as u16).to_le_bytes()); + central.extend([0; 12]); + central.extend(offset.to_le_bytes()); + central.extend(name.as_bytes()); + } + let offset = bytes.len() as u32; + let size = central.len() as u32; + bytes.extend(central); + bytes.extend(0x06054b50_u32.to_le_bytes()); + bytes.extend([0; 4]); + for _ in 0..2 { + bytes.extend((members.len() as u16).to_le_bytes()); + } + bytes.extend(size.to_le_bytes()); + bytes.extend(offset.to_le_bytes()); + bytes.extend([0; 2]); + bytes +} + +#[test] +fn reads_exact_ordinary_members_with_or_without_descriptors() { + for descriptor in [false, true] { + let bytes = zip( + &[ + ("trusted-server-fastly.wasm", b"final bytes"), + ("trusted-server-fastly.wasm.spdx.json", b"{}"), + ("trusted-server-fastly.tar.gz", b"package"), + ], + descriptor, + ); + let members = + contents(&bytes, Profile::Build).expect("should validate the ordinary build archive"); + assert_eq!( + members["trusted-server-fastly.wasm"], b"final bytes", + "should retain exact final bytes" + ); + assert_eq!( + members.len(), + 3, + "should retain only the fixed exact members" + ); + } +} + +#[test] +fn ordinary_profiles_reject_missing_extra_duplicate_and_cross_role_members() { + for members in [ + vec![("trusted-server-fastly.wasm", b"x".as_slice())], + vec![ + ("trusted-server-fastly.wasm", b"x".as_slice()), + ("trusted-server-fastly.wasm.spdx.json", b"{}"), + ("trusted-server-fastly.tar.gz", b"package"), + ("extra", b"x"), + ], + vec![ + ("trusted-server-fastly.wasm", b"x".as_slice()), + ("trusted-server-fastly.wasm", b"x"), + ], + vec![ + ("trusted-server-fastly.wasm", b"x".as_slice()), + ("fastly-observation.json", b"{}"), + ], + ] { + assert!( + contents(&zip(&members, false), Profile::Build).is_err(), + "should reject an archive outside the exact ordinary build profile" + ); + } + let observation = zip(&[("fastly-observation.json", b"{}")], true); + assert_eq!( + contents(&observation, Profile::Observation) + .expect("should accept the exact observation member")["fastly-observation.json"], + b"{}", + "should retain only the opaque observation bytes" + ); + assert!( + contents(&observation, Profile::Build).is_err(), + "should not treat observation archives as build archives" + ); +} + +#[test] +fn ordinary_metadata_bounds_are_checked_before_decompression() { + let mut bytes = zip(&[("fastly-observation.json", b"{}")], false); + let end = bytes.len() - 22; + let central = u32::from_le_bytes( + bytes[end + 16..end + 20] + .try_into() + .expect("should read fixture central offset"), + ) as usize; + bytes[central + 24..central + 28].copy_from_slice(&(1024 * 1024 + 1_u32).to_le_bytes()); + let error = contents(&bytes, Profile::Observation) + .expect_err("should reject role overflow before allocation or inflation"); + assert!( + format!("{error:?}").contains("member identity or size rejected"), + "should reach the source-owned role bound before CRC/decompression" + ); +} + +#[test] +fn ordinary_profiles_reject_empty_members_and_truncation() { + let bytes = zip(&[("fastly-observation.json", b"{}")], false); + for length in 0..bytes.len() { + assert!( + contents(&bytes[..length], Profile::Observation).is_err(), + "should reject a truncated ordinary archive at {length}" + ); + } + assert!( + contents( + &zip(&[("fastly-observation.json", b"")], false), + Profile::Observation + ) + .is_err(), + "should reject an empty observation instead of producing a placeholder" + ); +} + +#[test] +fn zip_rejects_paths_duplicates_hidden_data_and_corrupt_descriptors() { + for name in [ + "../artifact", + "/artifact", + "a/artifact", + "a\\artifact", + "Artifact", + ".", + "", + ] { + let bytes = zip(&[(name, b"x")], false); + assert!( + super::read(&bytes, &BTreeMap::from([("artifact".to_owned(), 1)])).is_err(), + "should reject unsafe or undeclared {name:?}" + ); + } + for descriptor in [false, true] { + let bytes = zip(&[("artifact", b"x"), ("artifact", b"x")], descriptor); + assert!( + super::read(&bytes, &BTreeMap::from([("artifact".to_owned(), 1)])).is_err(), + "should reject duplicate members" + ); + } + let mut bytes = zip(&[("artifact", b"x")], true); + bytes[38] ^= 1; + assert!( + super::read(&bytes, &BTreeMap::from([("artifact".to_owned(), 1)])).is_err(), + "should reject corrupt member bytes" + ); + let mut bytes = zip(&[("artifact", b"x")], true); + bytes[43] ^= 1; + assert!( + super::read(&bytes, &BTreeMap::from([("artifact".to_owned(), 1)])).is_err(), + "should reject descriptor mismatch" + ); + let mut bytes = zip(&[("artifact", b"x")], false); + bytes.insert(0, 0); + assert!( + super::read(&bytes, &BTreeMap::from([("artifact".to_owned(), 1)])).is_err(), + "should reject leading hidden bytes" + ); + let mut bytes = zip(&[("artifact", b"x")], false); + bytes.push(0); + assert!( + super::read(&bytes, &BTreeMap::from([("artifact".to_owned(), 1)])).is_err(), + "should reject trailing hidden bytes" + ); +} + +#[test] +fn zip_rejects_oversized_declarations_and_unsupported_features() { + let declared = BTreeMap::from([("artifact".to_owned(), 1)]); + for index in [6, 8] { + let mut bytes = zip(&[("artifact", b"x")], false); + bytes[index] = 1; + assert!( + super::read(&bytes, &declared).is_err(), + "should reject encryption or unsupported method" + ); + } + let bytes = zip(&[("artifact", b"x")], false); + assert!( + super::read(&bytes, &BTreeMap::from([("artifact".to_owned(), u64::MAX)])).is_err(), + "should reject declaration before allocation" + ); + for length in 0..bytes.len() { + assert!( + super::read(&bytes[..length], &declared).is_err(), + "should reject truncation at {length}" + ); + } +} + +#[test] +fn zip_deflate_preserves_full_bytes_and_rejects_extra_compressed_data() { + let body = vec![b'x'; 128 * 1024 + 17]; + for descriptor in [false, true] { + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(&body).expect("should compress fixture"); + let compressed = encoder.finish().expect("should finish compression"); + let mut bytes = zip(&[("artifact", &body)], descriptor); + bytes.splice(38..38 + body.len(), compressed.iter().copied()); + let central = 38 + compressed.len() + if descriptor { 16 } else { 0 }; + bytes[8..10].copy_from_slice(&8_u16.to_le_bytes()); + bytes[18..22].copy_from_slice( + &(if descriptor { + 0 + } else { + compressed.len() as u32 + }) + .to_le_bytes(), + ); + bytes[central + 10..central + 12].copy_from_slice(&8_u16.to_le_bytes()); + bytes[central + 20..central + 24].copy_from_slice(&(compressed.len() as u32).to_le_bytes()); + if descriptor { + bytes[38 + compressed.len() + 8..38 + compressed.len() + 12] + .copy_from_slice(&(compressed.len() as u32).to_le_bytes()); + } + let end = bytes.len() - 22; + bytes[end + 16..end + 20].copy_from_slice(&(central as u32).to_le_bytes()); + let declared = BTreeMap::from([("artifact".to_owned(), body.len() as u64)]); + assert_eq!( + super::read(&bytes, &declared).expect("should validate deflated stream")["artifact"], + body, + "should decompress exact full bytes" + ); + bytes[central + 24..central + 28].copy_from_slice(&1_u32.to_le_bytes()); + assert!( + super::read(&bytes, &BTreeMap::from([("artifact".to_owned(), 1)])).is_err(), + "should reject expanded bytes beyond declared bound" + ); + } +} + +#[test] +fn zip_rejects_symlinks_zip64_and_local_central_disagreement() { + let original = zip(&[("artifact", b"x")], false); + let central = 39; + let declared = BTreeMap::from([("artifact".to_owned(), 1)]); + for (offset, bytes) in [ + (central + 38, (0o120777_u32 << 16).to_le_bytes().to_vec()), + (central + 20, u32::MAX.to_le_bytes().to_vec()), + (central + 42, 1_u32.to_le_bytes().to_vec()), + (central + 6, 45_u16.to_le_bytes().to_vec()), + (14, 1_u32.to_le_bytes().to_vec()), + (30, b"different".to_vec()), + ] { + let mut changed = original.clone(); + changed[offset..offset + bytes.len()].copy_from_slice(&bytes); + assert!( + super::read(&changed, &declared).is_err(), + "should reject ZIP ambiguity at offset {offset}" + ); + } + let bytes = zip(&[("artifact", b"x"), ("Artifact", b"x")], false); + let declared = BTreeMap::from([("artifact".to_owned(), 1), ("Artifact".to_owned(), 1)]); + assert!( + super::read(&bytes, &declared).is_err(), + "should reject case-colliding declared identities" + ); +} + +#[test] +fn zip_requires_deflate_stream_end_even_when_crc_size_and_consumption_match() { + for descriptor in [false, true] { + for (body, nonfinal) in [ + (b"x".as_slice(), b"\x00\x01\x00\xfe\xffx".as_slice()), + (b"".as_slice(), b"\x00\x00\x00\xff\xff".as_slice()), + ] { + let declared = BTreeMap::from([("artifact".to_owned(), body.len() as u64)]); + let truncated = raw_deflate_zip(body, nonfinal, descriptor); + assert!( + super::read(&truncated, &declared).is_err(), + "should reject nonfinal raw DEFLATE block despite exact output, CRC and consumed bytes" + ); + let mut final_block = nonfinal.to_vec(); + final_block[0] |= 1; + let valid = raw_deflate_zip(body, &final_block, descriptor); + let mut trailing = final_block.clone(); + trailing.push(0); + assert!( + super::read(&raw_deflate_zip(body, &trailing, descriptor), &declared).is_err(), + "should reject compressed bytes after the final DEFLATE marker" + ); + for length in 0..final_block.len() { + assert!( + super::read( + &raw_deflate_zip(body, &final_block[..length], descriptor), + &declared + ) + .is_err(), + "should reject every truncated final DEFLATE block" + ); + } + assert_eq!( + super::read(&valid, &declared).expect("should accept explicit final DEFLATE block") + ["artifact"], + body, + "should retain exact terminated stream output" + ); + } + } +} + +fn raw_deflate_zip(body: &[u8], compressed: &[u8], descriptor: bool) -> Vec { + let mut bytes = zip(&[("artifact", body)], descriptor); + bytes.splice(38..38 + body.len(), compressed.iter().copied()); + let central = 38 + compressed.len() + if descriptor { 16 } else { 0 }; + bytes[8..10].copy_from_slice(&8_u16.to_le_bytes()); + bytes[18..22].copy_from_slice( + &(if descriptor { + 0 + } else { + compressed.len() as u32 + }) + .to_le_bytes(), + ); + bytes[central + 10..central + 12].copy_from_slice(&8_u16.to_le_bytes()); + bytes[central + 20..central + 24].copy_from_slice(&(compressed.len() as u32).to_le_bytes()); + if descriptor { + bytes[38 + compressed.len() + 8..38 + compressed.len() + 12] + .copy_from_slice(&(compressed.len() as u32).to_le_bytes()); + } + let end = bytes.len() - 22; + bytes[end + 16..end + 20].copy_from_slice(&(central as u32).to_le_bytes()); + bytes +} diff --git a/crates/trusted-server-release/src/ordinary/cargo.rs b/crates/trusted-server-release/src/ordinary/cargo.rs new file mode 100644 index 0000000000..b6e849b47d --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/cargo.rs @@ -0,0 +1,273 @@ +//! Ordinary compiler path policy and bounded Cargo message parsing. + +use crate::documents::{Role, parse_json}; +use crate::error::ReleaseError; +use error_stack::Report; +use serde_json::Value; +use std::io; +use std::path::Path; + +const CARGO_CONFIG: &str = ".cargo/config.toml"; +const LEGACY_CARGO_CONFIG: &str = ".cargo/config"; +/// Fixed source prefix in the final wasm. +pub(crate) const REMAPPED_CHECKOUT: &str = "/trusted-server"; +/// Fixed prefix for each role's private Cargo home. +pub(crate) const REMAPPED_CARGO_HOME: &str = "/cargo-home"; +/// Fixed build-output prefix in the final wasm. +pub(crate) const REMAPPED_TARGET: &str = "/cargo-target"; +const EXECUTION_MOUNT_PREFIX: &str = "/boundary/"; +const MAX_FORBIDDEN: usize = 64; +const MAX_PREFIX_BYTES: usize = 4096; +fn recipe_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Recipe).attach(detail.to_string()) +} + +/// Encodes a target-scoped path-remapping argument without TOML escaping. +/// +/// # Errors +/// +/// Rejects unsafe target names and flags. +pub(crate) fn rustflags_arguments( + triple: &str, + flags: &[String], +) -> Result, Report> { + if triple.is_empty() + || !triple + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + || flags + .iter() + .any(|flag| flag.contains(['"', '\\']) || flag.chars().any(char::is_control)) + { + return Err(recipe_error("target rustflags cannot be safely encoded")); + } + let flags: Vec<_> = flags.iter().map(|flag| format!("\"{flag}\"")).collect(); + Ok(vec![ + "--config".to_owned(), + format!("target.\"{triple}\".rustflags=[{}]", flags.join(",")), + ]) +} + +/// Refuses build flags that the target-scoped remapping would supersede. +/// +/// # Errors +/// +/// Rejects malformed configuration or a `build.rustflags` setting. +pub(crate) fn check_cargo_config(bytes: &[u8]) -> Result<(), Report> { + let text = std::str::from_utf8(bytes) + .map_err(|error| recipe_error(format!("{CARGO_CONFIG} is not UTF-8: {error}")))?; + let table: toml::Table = toml::from_str(text) + .map_err(|error| recipe_error(format!("{CARGO_CONFIG} is not TOML: {error}")))?; + let sets_build_rustflags = table + .get("build") + .and_then(toml::Value::as_table) + .is_some_and(|build| build.contains_key("rustflags")); + if sets_build_rustflags { + return Err(recipe_error(format!( + "{CARGO_CONFIG} sets build.rustflags, which the target path remap would supersede" + ))); + } + Ok(()) +} + +/// Refuses Cargo's unreviewed legacy configuration layer. +/// +/// # Errors +/// +/// Rejects a tracked, existing or uninspectable extensionless configuration. +pub(crate) fn check_no_extensionless_config( + checkout: &Path, + tracked: bool, +) -> Result<(), Report> { + if tracked { + return Err(recipe_error(format!( + "the approved source tracks {LEGACY_CARGO_CONFIG}, which is refused" + ))); + } + match std::fs::symlink_metadata(checkout.join(LEGACY_CARGO_CONFIG)) { + Ok(_) => Err(recipe_error(format!( + "the checkout holds {LEGACY_CARGO_CONFIG}, which is refused" + ))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(recipe_error(format!( + "{LEGACY_CARGO_CONFIG} cannot be inspected: {error}" + ))), + } +} + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +/// Rejects any retained physical build root in final bytes. +/// +/// # Errors +/// +/// Rejects invalid prefix bounds or a matching physical/container path. +pub(crate) fn reject_execution_prefixes( + bytes: &[u8], + forbidden: &[&str], +) -> Result<(), Report> { + if forbidden.len() > MAX_FORBIDDEN { + return Err(recipe_error("too many forbidden prefixes for one scan")); + } + for prefix in forbidden.iter().copied().chain([EXECUTION_MOUNT_PREFIX]) { + if prefix.is_empty() || prefix.len() > MAX_PREFIX_BYTES { + return Err(recipe_error("forbidden prefix is empty or too long")); + } + if contains(bytes, prefix.as_bytes()) { + return Err(recipe_error(format!( + "final bytes still hold the unremapped path prefix {prefix:?}" + ))); + } + } + Ok(()) +} + +fn output_error(detail: impl std::fmt::Display) -> Report { + recipe_error(format!("cargo output: {detail}")) +} + +fn messages(stdout: &[u8]) -> Result, Report> { + if stdout.len() > 16 * 1024 * 1024 { + return Err(output_error("Cargo stream exceeds the build output bound")); + } + if stdout.is_empty() { + return Ok(Vec::new()); + } + let body = stdout + .strip_suffix(b"\n") + .ok_or_else(|| output_error("final line is not newline-terminated"))?; + body.split(|byte| *byte == b'\n') + .enumerate() + .map(|(index, line)| { + let value = parse_json(line, Role::CargoMessage).map_err(|error| { + error.change_context(ReleaseError::Recipe).attach(format!( + "cargo output line {} is not strict JSON", + index + 1 + )) + })?; + let has_reason = value + .as_object() + .is_some_and(|object| object.get("reason").is_some_and(Value::is_string)); + if !has_reason { + return Err(output_error(format!( + "line {} is not an object with a string reason", + index + 1 + ))); + } + Ok(value) + }) + .collect() +} + +fn require_success(messages: &[Value]) -> Result<(), Report> { + let reason = |message: &Value| message["reason"].as_str().map(str::to_owned); + let finished = messages + .iter() + .filter(|message| reason(message).as_deref() == Some("build-finished")) + .count(); + let last = messages.last(); + let ends_finished = + last.is_some_and(|message| reason(message).as_deref() == Some("build-finished")); + if finished != 1 || !ends_finished { + return Err(output_error( + "stream must end with exactly one build-finished message", + )); + } + let success = last + .and_then(|message| message.get("success")) + .and_then(Value::as_bool); + if success != Some(true) { + return Err(output_error("build-finished did not report success true")); + } + Ok(()) +} + +/// Parses a bounded Cargo stream ending in exactly one successful completion. +/// +/// # Errors +/// +/// Rejects malformed, oversized or incomplete Cargo output. +pub(crate) fn successful_messages(stdout: &[u8]) -> Result, Report> { + let messages = messages(stdout)?; + require_success(&messages)?; + Ok(messages) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn lines(values: &[Value]) -> Vec { + values + .iter() + .flat_map(|value| { + let mut bytes = serde_json::to_vec(value).expect("should encode Cargo message"); + bytes.push(b'\n'); + bytes + }) + .collect() + } + + #[test] + fn build_requires_exactly_one_final_successful_completion() { + let artifact = json!({"reason":"compiler-artifact"}); + let finished = json!({"reason":"build-finished","success":true}); + let good = lines(&[artifact.clone(), finished.clone()]); + successful_messages(&good).expect("should accept a successful stream"); + for values in [ + vec![], + vec![artifact.clone()], + vec![json!({"reason":"build-finished","success":false})], + vec![json!({"reason":"build-finished","success":"true"})], + vec![finished.clone(), artifact], + vec![finished.clone(), finished], + vec![json!({"reason":"build-finished"})], + ] { + assert!( + successful_messages(&lines(&values)).is_err(), + "should reject incomplete or ambiguous completion" + ); + } + } + + #[test] + fn malformed_unterminated_and_oversized_streams_are_rejected() { + let good = lines(&[json!({"reason":"build-finished","success":true})]); + for prefix in [ + b"Compiling example\n".as_slice(), + b"\n", + b"[]\n", + b"null\n", + b"{\"x\":1}\n", + b"{\"reason\":1}\n", + b"{\"reason\":\"a\",\"reason\":\"b\"}\n", + b"{\"reason\":\"\xff\"}\n", + ] { + assert!( + successful_messages(&[prefix, &good].concat()).is_err(), + "should reject malformed Cargo line" + ); + } + assert!( + successful_messages(&good[..good.len() - 1]).is_err(), + "should require a final newline" + ); + let oversized_line = + lines(&[json!({"reason":"compiler-artifact","padding":"x".repeat(1024*1024)})]); + assert!( + successful_messages(&[oversized_line, good].concat()).is_err(), + "should bound individual messages" + ); + assert!( + successful_messages(&vec![b' '; 16 * 1024 * 1024 + 1]).is_err(), + "should bound the entire Cargo stream" + ); + } +} diff --git a/crates/trusted-server-release/src/ordinary/commands.rs b/crates/trusted-server-release/src/ordinary/commands.rs new file mode 100644 index 0000000000..d6df4a5a45 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/commands.rs @@ -0,0 +1,224 @@ +//! Explicit ordinary command assembly, without authenticated or qualified authority. + +use std::ffi::OsString; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde_json::{Value, json}; + +use super::{diagnostic, inputs, normalization, output, require, root, run, transfer}; +use crate::documents::{self, Role}; +use crate::error::ReleaseError; + +const ARCHIVES: [&str; 4] = [ + "canonical.zip", + "canonical-observation.zip", + "reproduction.zip", + "reproduction-observation.zip", +]; + +/// Checks fresh runner layout and seals the exact Cargo arguments before build. +/// +/// # Errors +/// +/// Rejects reused/aliased paths, competing flags or a failed private atomic write. +pub(crate) fn prepare_rustflags( + checkout: &Path, + target: &Path, + role: &str, + output: &Path, + inherited: &[(OsString, OsString)], +) -> Result> { + let flags = normalization::prepare(checkout, target, role, inherited)?; + output::rustflags(output, &flags.arguments)?; + Ok(flags.arguments[1].clone()) +} + +fn directory(path: &Path) -> Result<(), Report> { + require( + path.to_str().is_some_and(root) + && std::fs::canonicalize(path).is_ok_and(|resolved| resolved == path) + && std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()), + "ordinary command input root must be a canonical directory", + ) +} + +fn bind(input: &Path) -> Result> { + transfer_root(input)?; + let context = transfer_read(&input.join("context.json"), Role::Metadata)?; + let jobs = transfer_read(&input.join("jobs.json"), Role::Metadata)?; + let artifacts = transfer_read(&input.join("artifacts.json"), Role::Metadata)?; + run::bind(&context.bytes, &jobs.bytes, &artifacts.bytes).map_err(|report| { + diagnostic::tag( + report.change_context(ReleaseError::RebuildIdentity), + diagnostic::Code::IdentityMismatch, + ) + }) +} + +fn available(path: &Path) -> Result<(), Report> { + std::fs::symlink_metadata(path) + .map(|_| ()) + .map_err(|error| { + let code = if error.kind() == ErrorKind::NotFound { + diagnostic::Code::TransferUnavailable + } else { + diagnostic::Code::TransferRejected + }; + Report::new(ReleaseError::Document) + .attach(error) + .attach(diagnostic::Failure::new(code)) + }) +} + +fn transfer_root(path: &Path) -> Result<(), Report> { + available(path)?; + directory(path).map_err(|report| diagnostic::tag(report, diagnostic::Code::TransferRejected)) +} + +fn transfer_read(path: &Path, role: Role) -> Result> { + available(path)?; + documents::read_bounded(path, role) + .map_err(|report| diagnostic::tag(report, diagnostic::Code::TransferRejected)) +} + +/// Plans exact-ID downloads only after joining direct outputs and service records. +/// +/// # Errors +/// +/// Rejects aliased roots, unsafe/oversized metadata and any invalid current-run +/// identity. The caller must obtain service bytes over authenticated transport. +pub(crate) fn transfer_plan(input: &Path) -> Result> { + let bindings = bind(input)?; + let [canonical, reproduction] = bindings.builds(); + let archives: Vec<_> = [ + &canonical.artifact, + &canonical.observation, + &reproduction.artifact, + &reproduction.observation, + ] + .into_iter() + .zip(ARCHIVES) + .map(|(archive, file)| { + json!({"id":archive.id,"size":archive.size,"sha256":archive.sha256,"file":file}) + }) + .collect(); + Ok(json!({"version":1,"archives":archives})) +} + +/// Compares retained transfer archives and writes only an actual matched companion. +/// +/// # Errors +/// +/// Rejects invalid bindings, unsafe/changed archives, any byte/input mismatch or +/// an output that cannot be freshly and atomically sealed. No failed receipt is published. +pub(crate) fn compare_transfers( + input: &Path, + archives: &Path, + source: &Path, + output: &Path, +) -> Result> { + let mut artifact_ids = None; + let result = compare_retained(input, archives, source, output, &mut artifact_ids); + result.map_err(|report| { + let report = diagnostic::tag(report, diagnostic::Code::TransferUnavailable); + if diagnostic::retain(&report, output, artifact_ids).is_err() { + return report.attach( + "outcome=failed: ordinary diagnostic writer failed; no successful comparison", + ); + } + report + }) +} + +fn compare_retained( + input: &Path, + archives: &Path, + source: &Path, + output: &Path, + artifact_ids: &mut Option<[u64; 4]>, +) -> Result> { + let bindings = bind(input)?; + let [canonical, reproduction] = bindings.builds(); + *artifact_ids = Some([ + canonical.artifact.id, + canonical.observation.id, + reproduction.artifact.id, + reproduction.observation.id, + ]); + transfer_root(archives)?; + let retained = ARCHIVES + .into_iter() + .enumerate() + .map(|(index, name)| { + let role = if index % 2 == 0 { + Role::ArtifactArchive + } else { + Role::ObservationArchive + }; + transfer_read(&archives.join(name), role) + }) + .collect::, _>>()?; + let checked = transfer::check( + bindings, + &transfer::Archives([ + &retained[0].bytes, + &retained[1].bytes, + &retained[2].bytes, + &retained[3].bytes, + ]), + ) + .map_err(|report| diagnostic::tag(report, diagnostic::Code::InputsRejected))?; + let receipt = checked.receipt(); + let source_commit = checked_source(&receipt).map_err(|report| { + diagnostic::tag_equal(report, diagnostic::Code::InputsRejected, &checked) + })?; + let identity = inputs::source_identity(source, &source_commit).map_err(|report| { + diagnostic::tag_equal(report, diagnostic::Code::InputsRejected, &checked) + })?; + require( + receipt["recipe_sha256"] == identity.recipe_sha256 + && receipt["cargo_lock_sha256"] == identity.cargo_lock_sha256 + && receipt["npm_lock_sha256"] == identity.npm_lock_sha256, + "ordinary observations disagree with the independent comparison source", + ) + .map_err(|report| diagnostic::tag_equal(report, diagnostic::Code::InputsRejected, &checked))?; + inputs::verify_versions(source, &receipt["tools"]).map_err(|report| { + diagnostic::tag_equal(report, diagnostic::Code::InputsRejected, &checked) + })?; + output::companion(&checked, output) + .map_err(|report| diagnostic::tag_equal(report, diagnostic::Code::OutputRejected, &checked)) +} + +fn checked_source(receipt: &Value) -> Result> { + receipt["source_commit"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + Report::new(ReleaseError::Document).attach("ordinary receipt source is missing") + }) +} + +/// Measures one source-pinned ordinary build without granting recipe qualification. +/// +/// # Errors +/// +/// Rejects invalid captures, failed bounded version queries, changed input bytes +/// or unsafe output paths. Only the fixed Rust/Node version queries execute. +pub(crate) fn measure_build( + request: &Path, + output: &Path, +) -> Result> { + require( + request.to_str().is_some_and(root) + && std::fs::canonicalize(request).is_ok_and(|resolved| resolved == request), + "ordinary measurement request must use its canonical path", + )?; + let request = documents::read_bounded(request, Role::Metadata)?; + let observed = inputs::measure(&request.bytes)?; + output::observation(&observed, output) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/ordinary/commands/tests.rs b/crates/trusted-server-release/src/ordinary/commands/tests.rs new file mode 100644 index 0000000000..a00bffb6b0 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/commands/tests.rs @@ -0,0 +1,457 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; + +use super::{compare_transfers, transfer_plan}; + +struct Fixture { + _temporary: tempfile::TempDir, + source: crate::ordinary::inputs::tests::Fixture, + input: PathBuf, + archives: PathBuf, + output: PathBuf, +} + +impl Fixture { + fn new() -> Self { + Self::with_observations(|_| {}) + } + + fn with_observations(adjust: impl Fn(&mut serde_json::Value)) -> Self { + let temporary = tempfile::tempdir().expect("should create command fixture"); + let root = fs::canonicalize(temporary.path()).expect("should resolve fixture root"); + let input = root.join("input"); + let archives = root.join("archives"); + fs::create_dir(&input).expect("should create private metadata root"); + fs::create_dir(&archives).expect("should create private archives root"); + let source = crate::ordinary::inputs::tests::Fixture::new(); + fs::write( + source.checkout.join("rust-toolchain.toml"), + b"[toolchain]\nchannel = \"1.98.1\"\n", + ) + .expect("should retain comparison compiler pin"); + fs::write(source.checkout.join(".tool-versions"), b"nodejs 24.20.0\n") + .expect("should retain comparison Node pin"); + let (metadata, zip) = + crate::ordinary::transfer::tests::command_fixture(&source.checkout, adjust); + for (name, bytes) in ["context.json", "jobs.json", "artifacts.json"] + .into_iter() + .zip(metadata) + { + fs::write(input.join(name), bytes).expect("should retain service fixture bytes"); + } + for (name, bytes) in [ + "canonical.zip", + "canonical-observation.zip", + "reproduction.zip", + "reproduction-observation.zip", + ] + .into_iter() + .zip(zip) + { + fs::write(archives.join(name), bytes).expect("should retain exact ZIP fixture bytes"); + } + Self { + _temporary: temporary, + source, + input, + archives, + output: root.join("output"), + } + } +} + +#[test] +fn validated_download_plan_uses_four_fixed_names_and_direct_upload_ids() { + let fixture = Fixture::new(); + let value = transfer_plan(&fixture.input).expect("should validate exact current-run metadata"); + assert_eq!(value["version"], 1, "should version the private handoff"); + let archives = value["archives"] + .as_array() + .expect("should list exact downloads"); + assert_eq!(archives.len(), 4, "should plan only four bound archives"); + assert_eq!( + archives[0]["file"], "canonical.zip", + "should use a fixed local name" + ); + assert!( + archives + .iter() + .all(|item| item["id"].as_u64().is_some_and(|id| id > 0) + && item["size"].as_u64().is_some_and(|size| size > 0)), + "should retain service-correlated IDs and sizes" + ); +} + +#[test] +fn complete_transfer_command_seals_only_real_equality() { + let fixture = Fixture::new(); + let path = compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output, + ) + .expect("should compare and seal real bound archives"); + assert_eq!( + path.file_name().expect("should have fixed filename"), + "trusted-server-fastly.wasm.rebuild-check.json", + "should preserve the release contract" + ); + assert!( + !fixture + .output + .parent() + .expect("should have parent") + .join("ordinary-diagnostics") + .exists(), + "should write no diagnostic after success" + ); + let error = compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output, + ) + .expect_err("should reject the reused output directory"); + assert!( + format!("{error:?}").contains("ordinary output must use an absent root"), + "should reject reused outputs" + ); +} + +#[test] +fn altered_archive_or_service_identity_emits_no_publishable_companion() { + for alter_archive in [false, true] { + let fixture = Fixture::new(); + if alter_archive { + fs::write(fixture.archives.join("canonical.zip"), b"changed bytes") + .expect("should corrupt fixture archive"); + } else { + fs::write(fixture.input.join("jobs.json"), b"{}") + .expect("should remove authenticated job records"); + } + assert!( + compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output + ) + .is_err(), + "should fail before creating a result" + ); + assert!( + !fixture.output.exists(), + "should emit no placeholder or failed receipt" + ); + } +} + +#[test] +fn aliased_metadata_and_archive_roots_are_rejected() { + let fixture = Fixture::new(); + let root = fixture + .input + .parent() + .expect("should retain fixture parent"); + symlink(&fixture.input, root.join("input-alias")).expect("should alias metadata root"); + symlink(&fixture.archives, root.join("archive-alias")).expect("should alias archive root"); + assert!( + transfer_plan(&root.join("input-alias")).is_err(), + "should reject a metadata root alias" + ); + assert!( + compare_transfers( + &fixture.input, + &root.join("archive-alias"), + &fixture.source.checkout, + &fixture.output + ) + .is_err(), + "should reject an archive root alias" + ); + assert!( + !fixture.output.exists(), + "should leave no output after rejection" + ); +} + +#[test] +fn equally_claimed_inputs_must_match_the_independent_comparison_source() { + for (file, bytes) in [ + ("Cargo.lock", "changed lock"), + ( + "crates/trusted-server-js/lib/package-lock.json", + "changed npm lock", + ), + ("scripts/release/phase1-build.sh", "changed recipe"), + (".tool-versions", "nodejs 24.12.0\n"), + ] { + let fixture = Fixture::new(); + fs::write(fixture.source.checkout.join(file), bytes) + .expect("should change comparison source"); + assert!( + compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output + ) + .is_err(), + "should reject equal observations that do not match independently read source" + ); + assert!( + !fixture.output.exists(), + "should create no companion for invented input facts" + ); + } +} + +#[test] +fn equally_recorded_tool_versions_must_match_the_comparison_source_pins() { + for (tool, version) in [ + ("rustc", "rustc 1.97.0 (abcdef123 2026-01-01)"), + ("node", "v24.12.0"), + ] { + let fixture = Fixture::with_observations(|observed| { + observed["tools"][tool]["version"] = serde_json::json!(version); + }); + let error = compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output, + ) + .expect_err("should reject equal tool versions that differ from the source pins"); + assert!( + format!("{error:?}").contains("recorded tool versions differ"), + "should reach the compare-time {tool} pin check" + ); + assert!( + !fixture.output.exists(), + "should create no companion for an unpinned {tool}" + ); + } +} + +#[test] +fn malformed_measurement_request_fails_before_any_tool_query() { + let fixture = Fixture::new(); + let request = fixture.input.join("malformed.json"); + fs::write(&request, b"{}").expect("should write malformed capture without tool paths"); + assert!( + super::measure_build(&request, &fixture.output).is_err(), + "should reject missing fields before querying any executable" + ); + assert!( + !fixture.output.exists(), + "should create no observation after invalid capture" + ); +} + +#[test] +fn observation_archive_limit_is_applied_before_reading_its_body() { + let fixture = Fixture::new(); + fs::OpenOptions::new() + .write(true) + .open(fixture.archives.join("canonical-observation.zip")) + .expect("should open the retained observation ZIP") + .set_len(2 * 1024 * 1024 + 1) + .expect("should create a sparse oversized observation"); + let error = compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output, + ) + .expect_err("should reject the oversized observation before parsing"); + assert!( + format!("{error:?}").contains("input exceeds role byte limit"), + "should reject observation archives at their smaller bound" + ); + let diagnostic: serde_json::Value = serde_json::from_slice( + &fs::read( + fixture + .output + .parent() + .expect("should have parent") + .join("ordinary-diagnostics/ordinary-comparison-diagnostic.json"), + ) + .expect("should retain rejected-transfer diagnostic"), + ) + .expect("should parse bounded diagnostic"); + assert_eq!( + diagnostic["code"], "transfer_rejected", + "should distinguish unsafe oversized transfer from unavailable input" + ); + assert!( + !fixture.output.exists(), + "should write no companion for oversized input" + ); +} + +#[test] +fn failed_comparison_retains_bounded_diagnostic_with_explicit_unavailable_facts() { + let fixture = Fixture::new(); + fs::remove_file(fixture.archives.join("canonical.zip")) + .expect("should remove one unavailable transfer"); + let _ = compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output, + ) + .expect_err("should reject unavailable transfer"); + let bytes = fs::read( + fixture + .output + .parent() + .expect("should have parent") + .join("ordinary-diagnostics/ordinary-comparison-diagnostic.json"), + ) + .expect("should retain the structured failure diagnostic"); + assert!(bytes.len() <= 4096, "should enforce diagnostic byte cap"); + let value: serde_json::Value = + serde_json::from_slice(&bytes).expect("should parse the diagnostic"); + assert_eq!( + value["outcome"], "unavailable", + "should distinguish unavailable transfer" + ); + assert_eq!(value["stage"], "transfer", "should identify transfer stage"); + assert_eq!( + value["code"], "transfer_unavailable", + "should use fixed transfer code" + ); + assert_eq!( + value["facts"]["canonical_sha256"], "unavailable", + "should not invent unobserved binary facts" + ); + assert!( + !fixture.output.exists(), + "should never write a failed companion" + ); +} + +#[test] +fn failed_comparison_records_independent_source_input_failure() { + let fixture = Fixture::new(); + fs::write(fixture.source.checkout.join("Cargo.lock"), b"changed lock") + .expect("should alter independent source input"); + let _ = compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output, + ) + .expect_err("should fail independent source check"); + let bytes = fs::read( + fixture + .output + .parent() + .expect("should have parent") + .join("ordinary-diagnostics/ordinary-comparison-diagnostic.json"), + ) + .expect("should retain source input diagnostic"); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("should parse diagnostic"); + assert_eq!(value["outcome"], "failed", "should report failure"); + assert_eq!(value["stage"], "inputs", "should report input stage"); + assert!( + value["facts"]["canonical_sha256"] + .as_str() + .is_some_and(|digest| digest.len() == 64), + "should retain observed digest after source-input failure" + ); + assert_eq!( + value["facts"]["first_difference_offset"], "unavailable", + "should not invent difference after verified equality" + ); + assert_eq!( + value["code"], "source_inputs_rejected", + "should use fixed source code" + ); + assert!(!fixture.output.exists(), "should retain no companion"); +} + +#[test] +fn rejected_companion_writer_retains_observed_facts_without_fabricating_a_difference() { + let fixture = Fixture::new(); + fs::create_dir(&fixture.output).expect("should occupy companion destination"); + let _ = compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output, + ) + .expect_err("should refuse reused output"); + let value: serde_json::Value = serde_json::from_slice( + &fs::read( + fixture + .output + .parent() + .expect("should have parent") + .join("ordinary-diagnostics/ordinary-comparison-diagnostic.json"), + ) + .expect("should retain writer failure facts"), + ) + .expect("should parse writer diagnostic"); + assert_eq!( + value["stage"], "output", + "should identify failed companion sealing" + ); + assert_eq!( + value["code"], "companion_write_rejected", + "should retain fixed writer code" + ); + assert!( + value["facts"]["canonical_size"] + .as_u64() + .is_some_and(|size| size > 0), + "should retain actually observed binary size" + ); + assert_eq!( + value["facts"]["canonical_sha256"], value["facts"]["reproduction_sha256"], + "should retain established equal digests" + ); + assert_eq!( + value["facts"]["first_difference_offset"], "unavailable", + "should never invent a byte difference after equality" + ); + assert!( + !fixture + .output + .join("trusted-server-fastly.wasm.rebuild-check.json") + .exists(), + "should emit no companion on writer failure" + ); +} + +#[test] +fn diagnostic_writer_failure_remains_a_failed_comparison() { + let fixture = Fixture::new(); + fs::remove_file(fixture.archives.join("canonical.zip")).expect("should remove transfer"); + fs::write( + fixture + .output + .parent() + .expect("should have parent") + .join("ordinary-diagnostics"), + b"occupied", + ) + .expect("should prevent diagnostic directory creation"); + let error = compare_transfers( + &fixture.input, + &fixture.archives, + &fixture.source.checkout, + &fixture.output, + ) + .expect_err("should remain failed when diagnostic persistence fails"); + assert!( + format!("{error:?}").contains("outcome=failed: ordinary diagnostic writer failed"), + "should state failed outcome in human text" + ); + assert!( + !fixture.output.exists(), + "should never turn writer failure into a companion" + ); +} diff --git a/crates/trusted-server-release/src/ordinary/diagnostic.rs b/crates/trusted-server-release/src/ordinary/diagnostic.rs new file mode 100644 index 0000000000..186547faeb --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/diagnostic.rs @@ -0,0 +1,269 @@ +//! Bounded CI-only failure facts; never a publishable equality companion. + +use core::fmt; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::{output, require, transfer}; +use crate::documents::{self, Role}; +use crate::error::ReleaseError; + +const LIMIT: usize = 4096; + +/// Fixed classifications established at the rejecting check, never from stderr. +#[derive(Clone, Copy, Debug)] +pub(super) enum Code { + TransferUnavailable, + TransferRejected, + InputsRejected, + IdentityMismatch, + SizeMismatch, + DigestMismatch, + BytesMismatch, + OutputRejected, +} + +impl Code { + fn fields(self) -> (&'static str, &'static str, &'static str) { + match self { + Self::TransferUnavailable => ("unavailable", "transfer", "transfer_unavailable"), + Self::TransferRejected => ("failed", "transfer", "transfer_rejected"), + Self::InputsRejected => ("failed", "inputs", "source_inputs_rejected"), + Self::IdentityMismatch => ("failed", "identity", "identity_mismatch"), + Self::SizeMismatch => ("failed", "size", "observed_size_mismatch"), + Self::DigestMismatch => ("failed", "digest", "observed_digest_mismatch"), + Self::BytesMismatch => ("mismatched", "bytes", "final_bytes_mismatch"), + Self::OutputRejected => ("failed", "output", "companion_write_rejected"), + } + } +} + +/// Only fixed fields and actually measured, bounded facts can be serialized. +#[derive(Clone, Debug)] +pub(super) struct Failure { + code: Code, + asset: &'static str, + artifact_ids: Option<[u64; 4]>, + binaries: Option<[(u64, String); 2]>, + difference: Option<(u64, [String; 2])>, +} + +impl Failure { + /// Starts a classified failure with every unobserved fact unavailable. + pub(super) fn new(code: Code) -> Self { + Self { + code, + asset: super::ASSET, + artifact_ids: None, + binaries: None, + difference: None, + } + } + + /// Identifies measured deployment-package facts without relabeling the wasm. + pub(super) fn package(mut self) -> Self { + self.asset = super::PACKAGE; + self + } + + /// Measures only the two retained binary byte slices. + pub(super) fn binaries(mut self, left: &[u8], right: &[u8]) -> Self { + self.binaries = Some( + [left, right].map(|bytes| (bytes.len() as u64, hex::encode(Sha256::digest(bytes)))), + ); + self + } + + /// Retains the actual first difference and at most sixteen bytes per side. + pub(super) fn mismatch(left: &[u8], right: &[u8], offset: usize) -> Self { + let mut failure = Self::new(Code::BytesMismatch).binaries(left, right); + let windows = [left, right].map(|bytes| { + let start = offset.saturating_sub(8).min(bytes.len()); + hex::encode(&bytes[start..start.saturating_add(16).min(bytes.len())]) + }); + failure.difference = Some((offset as u64, windows)); + failure + } + + /// Projects only source-owned fields and measured facts for CI retention. + pub(super) fn value(&self) -> Value { + let unavailable = || json!("unavailable"); + let (outcome, stage, code) = self.code.fields(); + let id = |index| { + self.artifact_ids + .map(|ids| json!(ids[index])) + .unwrap_or_else(unavailable) + }; + let binary = |index: usize, digest| { + self.binaries + .as_ref() + .map(|facts| { + if digest { + json!(facts[index].1) + } else { + json!(facts[index].0) + } + }) + .unwrap_or_else(unavailable) + }; + json!({ + "kind":"ordinary-comparison-diagnostic", "version":1, + "asset":self.asset, + "outcome":outcome, "stage":stage, "code":code, + "facts":{ + "canonical_artifact_id":id(0), "canonical_observation_artifact_id":id(1), + "reproduction_artifact_id":id(2), "reproduction_observation_artifact_id":id(3), + "canonical_size":binary(0,false), "reproduction_size":binary(1,false), + "canonical_sha256":binary(0,true), "reproduction_sha256":binary(1,true), + "first_difference_offset":self.difference.as_ref().map(|facts| json!(facts.0)).unwrap_or_else(unavailable), + "canonical_window":self.difference.as_ref().map(|facts| json!(facts.1[0])).unwrap_or_else(unavailable), + "reproduction_window":self.difference.as_ref().map(|facts| json!(facts.1[1])).unwrap_or_else(unavailable), + } + }) + } +} + +impl fmt::Display for Failure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let (outcome, stage, code) = self.code.fields(); + write!( + formatter, + "ordinary diagnostic outcome={outcome} stage={stage} code={code}" + ) + } +} + +/// Classifies an unclassified failure without replacing deeper checked facts. +pub(super) fn tag(report: Report, code: Code) -> Report { + if report.contains::() { + report + } else { + report.attach(Failure::new(code)) + } +} + +/// Preserves the actual equal binary facts already retained by checked transfer. +pub(super) fn tag_equal( + report: Report, + code: Code, + checked: &transfer::Checked, +) -> Report { + if report.contains::() { + report + } else { + let (binary, _, _) = checked.inputs(); + report.attach(Failure::new(code).binaries(binary, binary)) + } +} + +fn encoded(value: &Value) -> Result> { + let bytes = documents::canonical_json(value, Role::Metadata)?; + require( + bytes.len() <= LIMIT, + "ordinary diagnostic exceeds 4096-byte cap", + )?; + Ok(value.clone()) +} + +/// Writes separate private failure evidence without touching the companion root. +/// +/// # Errors +/// +/// Rejects oversized diagnostics, unsafe or reused destinations and failed writes. +pub(super) fn retain( + report: &Report, + output: &Path, + artifact_ids: Option<[u64; 4]>, +) -> Result> { + let mut failure = report + .downcast_ref::() + .cloned() + .unwrap_or_else(|| Failure::new(Code::InputsRejected)); + failure.artifact_ids = artifact_ids; + let value = encoded(&failure.value())?; + let parent = output + .parent() + .ok_or_else(|| Report::new(ReleaseError::Document))?; + output::diagnostic(&parent.join("ordinary-diagnostics"), &value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_diagnostics_are_canonical_bounded_and_never_companions() { + for code in [ + Code::TransferUnavailable, + Code::TransferRejected, + Code::InputsRejected, + Code::IdentityMismatch, + Code::SizeMismatch, + Code::DigestMismatch, + Code::BytesMismatch, + Code::OutputRejected, + ] { + let value = Failure::new(code).value(); + let checked = encoded(&value).expect("should encode bounded fixed facts"); + let bytes = documents::canonical_json(&checked, Role::Metadata) + .expect("should serialize canonically"); + assert!(bytes.len() <= LIMIT, "should stay below the hard cap"); + assert!( + value.get("profile").is_none(), + "should never resemble a matched companion" + ); + assert!( + value["facts"] + .as_object() + .expect("should have facts") + .values() + .all(|fact| fact == "unavailable"), + "should mark every unobserved fact unavailable" + ); + } + assert!( + encoded(&json!({"unbounded":"x".repeat(LIMIT)})).is_err(), + "should refuse oversized diagnostics rather than truncate JSON" + ); + } + + #[test] + fn mismatch_retains_only_actual_binary_facts_and_bounded_windows() { + let value = Failure::mismatch(b"abc", b"abcd", 3).value(); + assert_eq!( + value["outcome"], "mismatched", + "should label actual final-byte inequality" + ); + assert_eq!( + value["stage"], "bytes", + "should not mistake accurately measured unequal lengths for observed-size corruption" + ); + assert_eq!( + value["facts"]["first_difference_offset"], 3, + "should retain actual prefix difference" + ); + assert_eq!( + value["facts"]["canonical_size"], 3, + "should measure canonical bytes" + ); + assert_eq!( + value["facts"]["reproduction_size"], 4, + "should measure reproduction bytes" + ); + assert_eq!( + value["facts"]["canonical_artifact_id"], "unavailable", + "should not invent service facts" + ); + assert!( + value["facts"]["canonical_window"] + .as_str() + .expect("should retain hex") + .len() + <= 32, + "should bound byte windows" + ); + } +} diff --git a/crates/trusted-server-release/src/ordinary/inputs.rs b/crates/trusted-server-release/src/ordinary/inputs.rs new file mode 100644 index 0000000000..cfc3e46736 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/inputs.rs @@ -0,0 +1,287 @@ +//! Ordinary source and consumed-private-JS identities, without qualification. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path}; + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::{digest, require, root}; +use crate::documents::{self, RawBytes, Role}; +use crate::error::ReleaseError; + +mod record; + +pub(super) use record::{measure, verify_versions}; + +/// Source-owned ordinary build input identities, independent of runner paths. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct SourceIdentity { + /// Canonical fixed recipe and source-file digest. + pub(super) recipe_sha256: String, + /// Raw Cargo lockfile digest, separate from the recipe identity. + pub(super) cargo_lock_sha256: String, + /// Raw npm lockfile digest, separate from the recipe identity. + pub(super) npm_lock_sha256: String, +} + +/// Hashes the fixed source-owned ordinary recipe and separate dependency locks. +/// +/// # Errors +/// +/// Rejects malformed source identity, aliased roots, or missing, empty, unsafe +/// or oversized fixed input files. This establishes no source approval. +pub(super) fn source_identity( + checkout: &Path, + source_commit: &str, +) -> Result> { + real_root(checkout)?; + require( + digest(source_commit, 40), + "ordinary source commit is invalid", + )?; + let mut files = BTreeMap::new(); + for file in [ + "scripts/release/phase1-build.sh", + "scripts/release/fastly.toml", + ".cargo/config.toml", + "Cargo.toml", + "rust-toolchain.toml", + ".tool-versions", + ] { + files.insert(file, source_file(checkout, file)?.sha256); + } + let recipe = json!({ + "version": 1, "source_commit": source_commit, + "package": "trusted-server-adapter-fastly", + "bin": "trusted-server-adapter-fastly", "target": "wasm32-wasip1", + "profile": "release", "default_features": true, "features": [], + "locked": true, "files": files, + "normalization": super::normalization::policy(), + }); + Ok(SourceIdentity { + recipe_sha256: hex::encode(Sha256::digest(documents::canonical_json( + &recipe, + Role::Metadata, + )?)), + cargo_lock_sha256: source_file(checkout, "Cargo.lock")?.sha256, + npm_lock_sha256: source_file(checkout, "crates/trusted-server-js/lib/package-lock.json")? + .sha256, + }) +} + +fn real_root(path: &Path) -> Result<(), Report> { + require( + path.to_str().is_some_and(root) + && fs::canonicalize(path).is_ok_and(|resolved| resolved == path) + && fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()), + "ordinary input root is missing or aliased", + ) +} + +fn source_file(root: &Path, relative: &str) -> Result> { + let path = root.join(relative); + require( + fs::canonicalize(&path).is_ok_and(|resolved| resolved == path), + "ordinary source input is missing or aliased", + )?; + let bytes = documents::read_bounded(&path, Role::Source)?; + require(!bytes.bytes.is_empty(), "ordinary source input is empty")?; + Ok(bytes) +} + +/// Hashes the exact private JS set named by the successful Cargo build stream. +/// +/// # Errors +/// +/// Rejects malformed or unsuccessful Cargo inputs, ambiguous package/results, +/// aliased paths, or a missing, extra, empty, unsafe or oversized bundle set. +/// The digest does not prove complete composition or embedded membership. +pub(super) fn javascript_identity( + checkout: &Path, + target: &Path, + metadata: &[u8], + messages: &[u8], +) -> Result> { + real_root(checkout)?; + real_root(target)?; + require( + !checkout.starts_with(target) && !target.starts_with(checkout), + "ordinary source and target roots overlap", + )?; + let metadata = + documents::parse_json(metadata, Role::CargoObservation).map_err(|_| js_error())?; + let package = js_package(checkout, &metadata)?; + let messages = crate::ordinary::cargo::successful_messages(messages).map_err(|_| js_error())?; + let matching: Vec<_> = messages + .iter() + .filter(|message| { + message["reason"] == "build-script-executed" && message["package_id"] == package + }) + .collect(); + require( + matching.len() == 1, + "ordinary JS build-script result is absent or ambiguous", + )?; + require( + matching[0]["env"].as_array().is_some_and(Vec::is_empty), + "ordinary JS result selected prebuilt or unexpected generated environment", + )?; + let text = matching[0]["out_dir"].as_str().ok_or_else(js_error)?; + require( + root(text), + "ordinary JS output path is not absolute and normal", + )?; + let output = Path::new(text); + let prefix = target.join("wasm32-wasip1/release/build"); + let relative = output.strip_prefix(&prefix).map_err(|_| js_error())?; + let parts: Vec<_> = relative.components().collect(); + require( + matches!(parts.as_slice(), [Component::Normal(package), Component::Normal(out)] if package.to_str().is_some_and(|name| name.starts_with("trusted-server-js-") && name.len() <= 128) && *out == "out"), + "ordinary JS output is outside the exact target-side build-script layout", + )?; + real_root(output)?; + let bundles = output.join("tsjs-dist"); + real_root(&bundles)?; + let expected = expected_bundles(checkout)?; + let mut found = BTreeSet::new(); + let mut records = BTreeMap::new(); + let mut total = 0_u64; + for entry in fs::read_dir(&bundles).map_err(|_| js_error())? { + let entry = entry.map_err(|_| js_error())?; + let name = entry.file_name().into_string().map_err(|_| js_error())?; + require( + expected.contains(&name) && found.insert(name.clone()), + "ordinary private JS has an unexpected or duplicate entry", + )?; + let raw = source_file(&bundles, &name)?; + total = total + .checked_add(raw.bytes.len() as u64) + .ok_or_else(js_error)?; + require( + total <= 512 * 1024 * 1024, + "ordinary private JS exceeds its total byte limit", + )?; + records.insert( + name.clone(), + json!({"name":name,"size":raw.bytes.len(),"sha256":raw.sha256}), + ); + } + require( + found == expected, + "ordinary private JS is missing a source-selected bundle", + )?; + let values: Vec<_> = records.into_values().collect(); + Ok(hex::encode(Sha256::digest(documents::canonical_json( + &json!(values), + Role::Metadata, + )?))) +} + +fn js_error() -> Report { + Report::new(ReleaseError::Document) + .attach("ordinary Cargo/JS input is missing, malformed or outside its fixed profile") +} + +fn js_package<'a>(checkout: &Path, metadata: &'a Value) -> Result<&'a str, Report> { + require( + metadata["workspace_root"].as_str().map(Path::new) == Some(checkout), + "ordinary Cargo metadata belongs to another checkout", + )?; + let packages = metadata["packages"] + .as_array() + .filter(|packages| packages.len() <= 5000) + .ok_or_else(js_error)?; + let mut ids = BTreeSet::new(); + for package in packages { + require( + package["id"].as_str().is_some_and(|id| { + !id.is_empty() + && id.len() <= 4096 + && !id.chars().any(char::is_control) + && ids.insert(id) + }), + "ordinary Cargo package ID is invalid or duplicated", + )?; + } + let manifest = checkout.join("crates/trusted-server-js/Cargo.toml"); + source_file(checkout, "crates/trusted-server-js/Cargo.toml")?; + let selected: Vec<_> = packages + .iter() + .filter(|package| { + package["name"] == "trusted-server-js" + && package["manifest_path"].as_str().map(Path::new) == Some(&manifest) + }) + .collect(); + require( + selected.len() == 1, + "ordinary Cargo metadata has no unique source-owned JS package", + )?; + let id = selected[0]["id"].as_str().ok_or_else(js_error)?; + let members = metadata["workspace_members"] + .as_array() + .ok_or_else(js_error)?; + require( + members + .iter() + .filter(|member| member.as_str() == Some(id)) + .count() + == 1, + "ordinary JS package is not a unique workspace member", + )?; + Ok(id) +} + +fn expected_bundles(checkout: &Path) -> Result, Report> { + let relative = "crates/trusted-server-js/lib/src"; + source_file(checkout, &format!("{relative}/core/index.ts"))?; + let integrations = checkout.join(relative).join("integrations"); + real_root(&integrations)?; + let mut expected = BTreeSet::from(["tsjs-core.js".to_owned()]); + let mut folded = BTreeSet::from(["core".to_owned()]); + let mut count = 0; + for entry in fs::read_dir(integrations).map_err(|_| js_error())? { + count += 1; + require( + count <= 256, + "ordinary JS source integration count exceeds its bound", + )?; + let entry = entry.map_err(|_| js_error())?; + let kind = entry.file_type().map_err(|_| js_error())?; + require( + !kind.is_symlink(), + "ordinary JS source integration is aliased", + )?; + if !kind.is_dir() + || !entry + .path() + .join("index.ts") + .try_exists() + .map_err(|_| js_error())? + { + continue; + } + let id = entry.file_name().into_string().map_err(|_| js_error())?; + require( + !id.is_empty() + && id.len() <= 96 + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + && folded.insert(id.to_ascii_lowercase()), + "ordinary JS source module identity is unsafe or colliding", + )?; + source_file(checkout, &format!("{relative}/integrations/{id}/index.ts"))?; + expected.insert(format!("tsjs-{id}.js")); + require( + expected.len() <= 128, + "ordinary JS bundle count exceeds its bound", + )?; + } + Ok(expected) +} + +#[cfg(test)] +pub(super) mod tests; diff --git a/crates/trusted-server-release/src/ordinary/inputs/record.rs b/crates/trusted-server-release/src/ordinary/inputs/record.rs new file mode 100644 index 0000000000..871a24ceec --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/inputs/record.rs @@ -0,0 +1,414 @@ +//! Records ordinary build facts without controlled-recipe authority. + +use std::ffi::OsString; +use std::fs; +use std::os::unix::fs::MetadataExt as _; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use error_stack::Report; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{SourceIdentity, javascript_identity, real_root, source_file, source_identity}; +use crate::documents::{self, Role}; +use crate::error::ReleaseError; +use crate::ordinary::{ + ASSET, Environment, GAPS, Observation, PROFILE, Tool, Tools, digest, normalization, + observation, require, root, +}; +use crate::process::{BoundedCommand, ProcessLimits}; + +#[derive(Debug, PartialEq, Eq)] +struct Pins { + rust: String, + node: String, +} + +fn rejected() -> Report { + Report::new(ReleaseError::Document) + .attach("ordinary measurement inputs or tool pins are invalid") +} + +fn version_pin(value: &str) -> bool { + value.len() <= 32 + && value.split('.').count() == 3 + && value.split('.').all(|part| { + !part.is_empty() + && part.bytes().all(|byte| byte.is_ascii_digit()) + && (part == "0" || !part.starts_with('0')) + }) +} + +fn pins(checkout: &Path) -> Result> { + let rust = source_file(checkout, "rust-toolchain.toml")?; + let node = source_file(checkout, ".tool-versions")?; + require( + rust.bytes.len() <= 64 * 1024 && node.bytes.len() <= 64 * 1024, + "ordinary source tool pin files exceed their bound", + )?; + let rust: toml::Value = + toml::from_str(std::str::from_utf8(&rust.bytes).map_err(|_| rejected())?) + .map_err(|_| rejected())?; + let rust = rust + .get("toolchain") + .and_then(|value| value.get("channel")) + .and_then(toml::Value::as_str) + .filter(|value| version_pin(value)) + .ok_or_else(rejected)? + .to_owned(); + let mut selected = Vec::new(); + for line in std::str::from_utf8(&node.bytes) + .map_err(|_| rejected())? + .lines() + { + let fields: Vec<_> = line.split_whitespace().collect(); + if fields.first() == Some(&"nodejs") { + require( + fields.len() == 2 && version_pin(fields[1]), + "ordinary Node selection must be one exact source pin", + )?; + selected.push(fields[1]); + } + } + require( + selected.len() == 1, + "ordinary source must contain exactly one Node pin", + )?; + Ok(Pins { + rust, + node: selected[0].to_owned(), + }) +} + +fn version_matches(kind: &str, pin: &str, version: &str) -> bool { + if !matches!(kind, "rustc" | "node") + || !version_pin(pin) + || version.len() > 512 + || version.chars().any(char::is_control) + { + return false; + } + if kind == "node" { + return version == format!("v{pin}"); + } + let prefix = format!("rustc {pin} ("); + let Some(body) = version + .strip_prefix(&prefix) + .and_then(|value| value.strip_suffix(')')) + else { + return false; + }; + let Some((hash, date)) = body.split_once(' ') else { + return false; + }; + (digest(hash, 9) || digest(hash, 40)) + && date.len() == 10 + && date.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 4 | 7) { + byte == b'-' + } else { + byte.is_ascii_digit() + } + }) +} + +/// Checks recorded versions against the independent comparison checkout's pins. +/// +/// # Errors +/// +/// Rejects ambiguous source pins or any recorded compiler/Node version mismatch. +pub(in crate::ordinary) fn verify_versions( + checkout: &Path, + value: &serde_json::Value, +) -> Result<(), Report> { + let pins = pins(checkout)?; + let tools: Tools = serde_json::from_value(value.clone()).map_err(|_| rejected())?; + require( + version_matches("rustc", &pins.rust, &tools.rustc.version) + && version_matches("node", &pins.node, &tools.node.version), + "ordinary recorded tool versions differ from comparison source pins", + ) +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Request { + role: String, + source_commit: String, + environment: Environment, + rustc: PathBuf, + node: PathBuf, + cargo_messages: PathBuf, + cargo_metadata: PathBuf, + rustflags: PathBuf, +} + +#[derive(PartialEq, Eq)] +struct RootIdentity { + device: u64, + inode: u64, + owner: u32, +} + +struct Captured { + request: Request, + roots: [RootIdentity; 3], +} + +fn execution_uid() -> u32 { + // SAFETY: geteuid has no memory preconditions and only reads process identity. + unsafe { libc::geteuid() } +} + +fn root_identities(request: &Request) -> Result<[RootIdentity; 3], Report> { + let identity = |path: &str| -> Result> { + let metadata = fs::symlink_metadata(path).map_err(|_| rejected())?; + require( + metadata.is_dir(), + "ordinary captured root is no longer a directory", + )?; + Ok(RootIdentity { + device: metadata.dev(), + inode: metadata.ino(), + owner: metadata.uid(), + }) + }; + Ok([ + identity(&request.environment.checkout_root)?, + identity(&request.environment.target_root)?, + identity(&request.environment.cargo_home)?, + ]) +} + +fn validate(request: &Request) -> Result<(), Report> { + let env = &request.environment; + let checkout = Path::new(&env.checkout_root); + let target = Path::new(&env.target_root); + let cargo_home = Path::new(&env.cargo_home); + real_root(checkout)?; + real_root(target)?; + real_root(cargo_home)?; + require( + normalization::cargo_home(target, &request.role).is_some_and(|home| home == cargo_home), + "ordinary capture Cargo home is not the role's private home", + )?; + require( + digest(&request.source_commit, 40) + && env.started_at_seconds > 0 + && env.locale == "C" + && env.timezone == "UTC" + && env.uid > 0 + && env.uid == execution_uid() + && matches!( + (request.role.as_str(), env.jobs, env.umask), + ("canonical", 4, 0o022) | ("reproduction", 2, 0o027) + ) + && !checkout.starts_with(target) + && !target.starts_with(checkout), + "ordinary capture source, role or environment is invalid", + )?; + let checkout_metadata = fs::metadata(checkout).map_err(|_| rejected())?; + let target_metadata = fs::metadata(target).map_err(|_| rejected())?; + let home_metadata = fs::metadata(cargo_home).map_err(|_| rejected())?; + require( + checkout_metadata.uid() == env.uid + && target_metadata.uid() == env.uid + && home_metadata.uid() == env.uid + && (checkout_metadata.dev(), checkout_metadata.ino()) + != (target_metadata.dev(), target_metadata.ino()), + "ordinary source/target ownership or filesystem identity is invalid", + )?; + for (path, kind) in [(&request.rustc, "rustc"), (&request.node, "node")] { + require( + path.to_str().is_some_and(root) + && path.file_name().is_some_and(|name| name == kind) + && fs::canonicalize(path).is_ok_and(|resolved| resolved == *path), + "ordinary tool path must name the actual canonical executable, not a shim", + )?; + } + for path in [ + &request.cargo_messages, + &request.cargo_metadata, + &request.rustflags, + ] { + require( + path.to_str().is_some_and(root) + && fs::canonicalize(path).is_ok_and(|resolved| resolved == *path), + "ordinary Cargo capture path is missing or aliased", + )?; + } + Ok(()) +} + +/// Requires every resolved package source, and every package the build +/// itself compiled, to come from the checkout or the role's private Cargo +/// home, proving Cargo used the remapped home. +fn dependency_sources( + checkout: &Path, + cargo_home: &Path, + metadata: &[u8], + messages: &[u8], +) -> Result<(), Report> { + let inside = |value: &Value| { + value.as_str().is_some_and(|path| { + let path = Path::new(path); + path.starts_with(checkout) || path.starts_with(cargo_home) + }) + }; + let metadata = + documents::parse_json(metadata, Role::CargoObservation).map_err(|_| rejected())?; + let packages = metadata["packages"].as_array().ok_or_else(rejected)?; + let messages = crate::ordinary::cargo::successful_messages(messages).map_err(|_| rejected())?; + require( + packages + .iter() + .all(|package| inside(&package["manifest_path"])) + && messages + .iter() + .filter(|message| message["reason"] == "compiler-artifact") + .all(|message| inside(&message["manifest_path"])), + "ordinary package source is outside the checkout and private Cargo home", + ) +} + +fn capture(bytes: &[u8]) -> Result> { + let request = serde_json::from_value( + documents::parse_json(bytes, Role::Metadata).map_err(|_| rejected())?, + ) + .map_err(|_| rejected())?; + validate(&request)?; + let roots = root_identities(&request)?; + require( + roots + .iter() + .all(|root| root.owner == request.environment.uid), + "ordinary captured root owner changed", + )?; + Ok(Captured { request, roots }) +} + +fn tool(program: &Path, working: &Path) -> Result> { + let before = documents::hash_bounded_with_check(program, Role::Executable.limit(), &|| Ok(()))?; + require( + before.size > 0 && before.mode & 0o111 != 0 && before.mode & 0o6000 == 0, + "ordinary selected tool is empty, privileged or not executable", + )?; + let raw = (|| { + BoundedCommand::new( + program, + working, + ProcessLimits { + wall: Duration::from_secs(10), + stdout_max: 1024, + stderr_max: 1024, + }, + )? + .arguments([OsString::from("--version")]) + .environment("LC_ALL", "C")? + .environment("TZ", "UTC")? + .run()? + .require_success() + })() + .map_err(|_| rejected())?; + let raw = raw.strip_suffix(b"\n").ok_or_else(rejected)?; + let version = std::str::from_utf8(raw).map_err(|_| rejected())?.to_owned(); + let after = documents::hash_bounded_with_check(program, Role::Executable.limit(), &|| Ok(()))?; + require( + before.sha256 == after.sha256 && before.size == after.size && before.mode == after.mode, + "ordinary selected tool changed while querying its version", + )?; + Ok(Tool { + version, + sha256: before.sha256, + }) +} + +fn finish( + captured: &Captured, + initial: &SourceIdentity, + pins: &Pins, + tools: &Tools, +) -> Result> { + let request = &captured.request; + validate(request)?; + require( + root_identities(request)? == captured.roots, + "ordinary captured root identity changed", + )?; + require( + version_matches("rustc", &pins.rust, &tools.rustc.version) + && version_matches("node", &pins.node, &tools.node.version), + "ordinary measured tool versions do not match the source pins", + )?; + let checkout = Path::new(&request.environment.checkout_root); + let target = Path::new(&request.environment.target_root); + let metadata = documents::read_bounded(&request.cargo_metadata, Role::CargoObservation)?; + let messages = documents::read_bounded(&request.cargo_messages, Role::CargoObservation)?; + let js = javascript_identity(checkout, target, &metadata.bytes, &messages.bytes)?; + dependency_sources( + checkout, + Path::new(&request.environment.cargo_home), + &metadata.bytes, + &messages.bytes, + )?; + let binary = checkout.join("release-assets").join(ASSET); + require( + fs::canonicalize(&binary).is_ok_and(|resolved| resolved == binary), + "ordinary final binary path is missing or aliased", + )?; + let binary = documents::read_bounded(&binary, Role::Executable)?; + let package = documents::read_bounded( + &checkout.join("release-assets").join(super::super::PACKAGE), + Role::ArtifactArchive, + )?; + let flags = normalization::plan(checkout, target, &request.role)?; + let retained = documents::read_bounded(&request.rustflags, Role::Metadata)?; + require( + retained.bytes == flags.bytes && retained.sha256 == flags.sha256, + "ordinary captured Cargo arguments differ from the source-owned six-map plan", + )?; + normalization::scan(&binary.bytes, &flags)?; + let source = source_identity(checkout, &request.source_commit)?; + require( + source == *initial, + "ordinary source inputs changed during measurement", + )?; + require( + root_identities(request)? == captured.roots, + "ordinary captured root identity changed before sealing", + )?; + let value = json!({ + "version":1,"profile":PROFILE,"release_qualification":false, + "source_commit":request.source_commit,"asset":ASSET,"target":"wasm32-wasip1","variant":"default","role":request.role, + "recipe_sha256":source.recipe_sha256,"cargo_lock_sha256":source.cargo_lock_sha256,"npm_lock_sha256":source.npm_lock_sha256, + "js_inventory_sha256":js,"rustflags_sha256":retained.sha256,"tools":tools,"environment":request.environment, + "artifact":{"size":binary.bytes.len(),"sha256":binary.sha256}, + "package":{"size":package.bytes.len(),"sha256":package.sha256},"gaps":GAPS, + }); + observation(&documents::canonical_json(&value, Role::Metadata)?) +} + +/// Records one ordinary observation from strict, source-owned invocation data. +/// +/// # Errors +/// +/// Rejects invalid captures, source/ownership changes, unsafe retained inputs, +/// failed bounded version queries, tool-pin mismatches or changed executables. +/// Authentication and controlled-recipe qualification remain unestablished. +pub(in crate::ordinary) fn measure(bytes: &[u8]) -> Result> { + let captured = capture(bytes)?; + let request = &captured.request; + let checkout = Path::new(&request.environment.checkout_root); + let source = source_identity(checkout, &request.source_commit)?; + let pins = pins(checkout)?; + let tools = Tools { + rustc: tool(&request.rustc, checkout)?, + node: tool(&request.node, checkout)?, + acquisition: "ordinary-hosted-toolchain".to_owned(), + }; + finish(&captured, &source, &pins, &tools) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/ordinary/inputs/record/tests.rs b/crates/trusted-server-release/src/ordinary/inputs/record/tests.rs new file mode 100644 index 0000000000..013f227465 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/inputs/record/tests.rs @@ -0,0 +1,518 @@ +use std::fs; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::path::Path; + +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::{Captured, Pins, Request, capture, finish, pins, version_matches}; +use crate::ordinary::inputs::tests::Fixture; +use crate::ordinary::inputs::{SourceIdentity, source_identity}; +use crate::ordinary::{ASSET, Environment, Tool, Tools}; + +#[test] +fn reads_exact_source_owned_rust_and_node_pins() { + let directory = tempfile::tempdir().expect("should create pinned source fixture"); + let root = fs::canonicalize(directory.path()).expect("should resolve fixture root"); + fs::write( + root.join("rust-toolchain.toml"), + b"[toolchain]\nchannel = \"1.98.1\"\n", + ) + .expect("should write pinned Rust source"); + fs::write( + root.join(".tool-versions"), + b"nodejs 24.20.0\nfastly 16.0.0\n", + ) + .expect("should write pinned Node source"); + assert_eq!( + pins(&root).expect("should read exact immutable source pins"), + Pins { + rust: "1.98.1".to_owned(), + node: "24.20.0".to_owned() + }, + "should use source files rather than request-supplied tool versions" + ); +} + +#[test] +fn rejects_floating_duplicate_and_malformed_source_tool_pins() { + for (rust, node) in [ + ("stable", "nodejs 24.20.0\n"), + ("1.98.1", "nodejs 24.20.0\nnodejs 24.20.0\n"), + ("1.98.1", "nodejs 24.20.0 25.0.0\n"), + ("1.98.1", "nodejs 024.20.0\n"), + ("1.98.1", "other 24.20.0\n"), + ] { + let fixture = Fixture::new(); + fs::write( + fixture.checkout.join("rust-toolchain.toml"), + format!("[toolchain]\nchannel = \"{rust}\"\n"), + ) + .expect("should write source pin"); + fs::write(fixture.checkout.join(".tool-versions"), node) + .expect("should write source Node selection"); + assert!( + pins(&fixture.checkout).is_err(), + "should reject a floating, ambiguous or malformed source tool selection" + ); + } +} + +#[test] +fn requires_exact_safe_version_output_for_each_source_selected_tool() { + assert!( + version_matches("rustc", "1.98.1", "rustc 1.98.1 (abcdef123 2026-01-01)"), + "should accept the exact compiler version line" + ); + assert!( + version_matches("node", "24.20.0", "v24.20.0"), + "should accept the exact Node version line" + ); + for (kind, pin, line) in [ + ("rustc", "1.98.1", "rustc 1.95.0 (abcdef123 2026-01-01)"), + ("rustc", "1.98.1", "rustc 1.98.1 (example secret)"), + ("rustc", "1.98.1", "rustc 1.98.1 (abcdef123 2026-01-01)\n"), + ("node", "24.20.0", "v24.20.0\nexample"), + ("node", "24.20.0", "v24.12.0"), + ("node", "24.20.0", "v24.20.0-extra"), + ("cargo", "1.98.1", "rustc 1.98.1 (abcdef123 2026-01-01)"), + ] { + assert!( + !version_matches(kind, pin, line), + "should reject mismatched, injected or unsupported version output" + ); + } +} + +struct CaptureFixture { + files: Fixture, + captured: Captured, + initial: SourceIdentity, + pins: Pins, +} + +impl CaptureFixture { + fn new() -> Self { + let files = Fixture::new(); + let (metadata, messages) = files.private_js(); + fs::write( + files.checkout.join("rust-toolchain.toml"), + b"[toolchain]\nchannel = \"1.98.1\"\n", + ) + .expect("should write Rust pin"); + fs::write(files.checkout.join(".tool-versions"), b"nodejs 24.20.0\n") + .expect("should write Node pin"); + let tools = files.checkout.join("tools/bin"); + fs::create_dir_all(&tools).expect("should create fictional tool root"); + for name in ["rustc", "node"] { + let file = tools.join(name); + fs::write(&file, name.as_bytes()).expect("should write fictional tool identity"); + fs::set_permissions(file, fs::Permissions::from_mode(0o755)) + .expect("should set fictional executable mode"); + } + fs::create_dir(files.checkout.join("release-assets")).expect("should create artifact root"); + fs::write( + files.checkout.join("release-assets").join(ASSET), + crate::ordinary::normalization::TEST_WASM, + ) + .expect("should write final artifact"); + fs::write( + files + .checkout + .join("release-assets/trusted-server-fastly.tar.gz"), + b"package", + ) + .expect("should write package"); + let cargo_metadata = files.target.join("metadata.json"); + let cargo_messages = files.target.join("messages.jsonl"); + fs::write( + &cargo_metadata, + serde_json::to_vec(&metadata).expect("should encode metadata"), + ) + .expect("should retain metadata"); + let bytes: Vec = messages + .iter() + .flat_map(|message| { + let mut bytes = serde_json::to_vec(message).expect("should encode message"); + bytes.push(b'\n'); + bytes + }) + .collect(); + fs::write(&cargo_messages, bytes).expect("should retain Cargo messages"); + let cargo_home = files + .target + .parent() + .expect("should have a temporary parent") + .join("ordinary-canonical-cargo-home"); + fs::create_dir(&cargo_home).expect("should create the private Cargo home"); + let rustflags = files.target.join("rustflags.json"); + let flags = + crate::ordinary::normalization::plan(&files.checkout, &files.target, "canonical") + .expect("should derive source-owned target flags"); + fs::write(&rustflags, flags.bytes).expect("should retain actual Cargo arguments"); + let uid = fs::metadata(&files.checkout) + .expect("should inspect source owner") + .uid(); + let request = Request { + role: "canonical".to_owned(), + source_commit: "a".repeat(40), + environment: Environment { + checkout_root: files + .checkout + .to_str() + .expect("should encode source root") + .to_owned(), + target_root: files + .target + .to_str() + .expect("should encode target root") + .to_owned(), + cargo_home: cargo_home + .to_str() + .expect("should encode Cargo home") + .to_owned(), + started_at_seconds: 1_700_000_000, + umask: 0o022, + jobs: 4, + locale: "C".to_owned(), + timezone: "UTC".to_owned(), + uid, + }, + rustc: tools.join("rustc"), + node: tools.join("node"), + cargo_messages, + cargo_metadata, + rustflags, + }; + let captured = + capture(&serde_json::to_vec(&request).expect("should encode explicit capture")) + .expect("should retain initial root identities"); + let initial = source_identity(&files.checkout, &captured.request.source_commit) + .expect("should measure initial source"); + let pins = pins(&files.checkout).expect("should bind source tool pins"); + Self { + files, + captured, + initial, + pins, + } + } + + fn tools(&self) -> Tools { + let digest = |path| { + hex::encode(Sha256::digest( + fs::read(path).expect("should measure fictional tool bytes"), + )) + }; + Tools { + rustc: Tool { + version: "rustc 1.98.1 (abcdef123 2026-01-01)".to_owned(), + sha256: digest(&self.captured.request.rustc), + }, + node: Tool { + version: "v24.20.0".to_owned(), + sha256: digest(&self.captured.request.node), + }, + acquisition: "ordinary-hosted-toolchain".to_owned(), + } + } + + fn value(&self) -> Value { + serde_json::to_value(&self.captured.request) + .expect("should encode only explicit capture fields") + } +} + +#[test] +fn assembles_only_whitelisted_ordinary_facts_from_retained_inputs() { + let fixture = CaptureFixture::new(); + let observed = finish( + &fixture.captured, + &fixture.initial, + &fixture.pins, + &fixture.tools(), + ) + .expect("should join measured ordinary inputs without executing a tool in this test"); + assert_eq!( + observed.value()["release_qualification"], + false, + "should not grant qualification" + ); + assert_eq!( + observed.value()["gaps"] + .as_array() + .expect("should record gaps") + .len(), + 4, + "should preserve every unsupported assurance" + ); + assert_eq!( + observed.value()["environment"] + .as_object() + .expect("should record explicit environment") + .len(), + 9, + "should serialize only the explicit capture allowlist" + ); + assert_eq!( + observed.value()["artifact"]["sha256"], + hex::encode(Sha256::digest(crate::ordinary::normalization::TEST_WASM)), + "should bind final retained bytes" + ); +} + +#[test] +fn rejects_wrong_source_versions_or_source_changes_before_recording() { + let fixture = CaptureFixture::new(); + let mut tools = fixture.tools(); + tools.node.version = "v24.12.0".to_owned(); + assert!( + finish(&fixture.captured, &fixture.initial, &fixture.pins, &tools).is_err(), + "should reject a version differing from source pins" + ); + fs::write( + fixture.files.checkout.join("Cargo.lock"), + b"changed source lock", + ) + .expect("should change lock after initial measurement"); + assert!( + finish( + &fixture.captured, + &fixture.initial, + &fixture.pins, + &fixture.tools() + ) + .is_err(), + "should reject source changes during measurement" + ); +} + +#[test] +fn rejects_changed_retained_arguments_and_unmapped_or_stripped_final_wasm() { + for change in ["arguments", "stripped", "physical-root"] { + let fixture = CaptureFixture::new(); + if change == "arguments" { + fs::write( + &fixture.captured.request.rustflags, + b"[\"--config\",\"wrong\"]\n", + ) + .expect("should alter actual retained arguments"); + } else { + let bytes = if change == "stripped" { + b"\0asm\x01\0\0\0".to_vec() + } else { + let mut bytes = crate::ordinary::normalization::TEST_WASM.to_vec(); + let root = fixture + .files + .checkout + .to_str() + .expect("should encode physical root"); + let data = root.as_bytes(); + // A custom section leaves all existing debug sections intact. + bytes.push(0); + let mut length = data.len() + 2; + loop { + let next = (length & 0x7f) as u8; + length >>= 7; + bytes.push(next | if length == 0 { 0 } else { 0x80 }); + if length == 0 { + break; + } + } + bytes.extend([1, b'x']); + bytes.extend(data); + bytes + }; + fs::write( + fixture.files.checkout.join("release-assets").join(ASSET), + bytes, + ) + .expect("should alter final Wasm evidence"); + } + assert!( + finish( + &fixture.captured, + &fixture.initial, + &fixture.pins, + &fixture.tools() + ) + .is_err(), + "should refuse altered retained argument bytes or debug/path evidence" + ); + } +} + +#[test] +fn rejects_malformed_unknown_and_false_capture_contexts_without_tool_execution() { + let fixture = CaptureFixture::new(); + for (pointer, value) in [ + ("/role", json!("other")), + ("/source_commit", json!("main")), + ("/environment/jobs", json!(2)), + ("/environment/umask", json!(0o027)), + ("/environment/locale", json!("example")), + ("/environment/timezone", json!("example")), + ("/environment/uid", json!(0)), + ("/environment/started_at_seconds", json!(0)), + ] { + let mut request = fixture.value(); + *request + .pointer_mut(pointer) + .expect("should locate fixture capture field") = value; + assert!( + capture(&serde_json::to_vec(&request).expect("should encode context")).is_err(), + "should reject false context {pointer} before tools could run" + ); + } + let mut request = fixture.value(); + request["credentials"] = json!("example-secret"); + assert!( + capture(&serde_json::to_vec(&request).expect("should encode unknown capture")).is_err(), + "should reject any additional inherited-environment field" + ); +} + +fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir(destination).expect("should create byte-identical replacement directory"); + for entry in fs::read_dir(source).expect("should enumerate retained fixture") { + let entry = entry.expect("should read fixture entry"); + let target = destination.join(entry.file_name()); + if entry + .file_type() + .expect("should inspect fixture entry") + .is_dir() + { + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).expect("should copy identical fixture bytes"); + } + } +} + +#[test] +fn rejects_byte_identical_checkout_or_target_root_substitution() { + for replace_target in [false, true] { + let fixture = CaptureFixture::new(); + let tools = fixture.tools(); + let root = if replace_target { + &fixture.files.target + } else { + &fixture.files.checkout + }; + let retained = root.with_extension("retained"); + fs::rename(root, &retained).expect("should retain the originally captured root"); + copy_tree(&retained, root); + assert!( + finish(&fixture.captured, &fixture.initial, &fixture.pins, &tools).is_err(), + "should reject replacing either captured root even when every file byte and owner matches" + ); + } +} + +#[test] +fn malformed_capture_errors_never_echo_untrusted_keys() { + let key = "example-key".repeat(10000); + let bytes = format!("{{\"{key}\":0,\"{key}\":1}}"); + let error = match capture(bytes.as_bytes()) { + Ok(_) => panic!("should reject duplicate capture keys"), + Err(error) => error, + }; + // Caller-enabled backtraces may expand Report's debug rendering. Bound the + // owned diagnostic itself and independently reject input in the full rendering. + let diagnostic = error + .downcast_ref::<&str>() + .expect("should retain the fixed diagnostic"); + assert_eq!( + *diagnostic, "ordinary measurement inputs or tool pins are invalid", + "should discard the original parser diagnostic" + ); + assert!( + diagnostic.len() < 512 && !format!("{error:?}").contains("example-key"), + "should retain a bounded fixed diagnostic for malformed captures" + ); +} + +#[test] +fn dependency_sources_must_come_from_the_checkout_or_private_cargo_home() { + let checkout = Path::new("/example/workspace/ordinary-canonical-source"); + let home = Path::new("/example/temp/ordinary-canonical-cargo-home"); + let metadata = |paths: &[&str]| { + serde_json::to_vec(&json!({ + "packages": paths.iter().map(|path| json!({"manifest_path": path})).collect::>() + })) + .expect("should encode metadata") + }; + let messages = |paths: &[&str]| { + let mut bytes: Vec = paths + .iter() + .flat_map(|path| { + let mut line = serde_json::to_vec( + &json!({"reason": "compiler-artifact", "manifest_path": path}), + ) + .expect("should encode compiler artifact"); + line.push(b'\n'); + line + }) + .collect(); + bytes.extend_from_slice(b"{\"reason\":\"build-finished\",\"success\":true}\n"); + bytes + }; + let accepted = [ + "/example/workspace/ordinary-canonical-source/crates/example/Cargo.toml", + "/example/temp/ordinary-canonical-cargo-home/registry/src/index/example-1.0.0/Cargo.toml", + ]; + super::dependency_sources(checkout, home, &metadata(&accepted), &messages(&accepted)) + .expect("should accept checkout and private Cargo home sources"); + for outside in [ + "/home/example/.cargo/registry/src/index/example-1.0.0/Cargo.toml", + "/example/temp/ordinary-reproduction-cargo-home/git/checkouts/example/Cargo.toml", + ] { + for (resolved, built) in [(outside, accepted[0]), (accepted[0], outside)] { + let error = super::dependency_sources( + checkout, + home, + &metadata(&[resolved]), + &messages(&[built]), + ) + .expect_err("should reject a source from another Cargo home"); + assert!( + format!("{error:?}").contains("outside the checkout and private Cargo home"), + "should reach the dependency source rule for {resolved} / {built}" + ); + } + } +} + +#[test] +fn recording_rejects_a_package_outside_the_checkout_and_private_cargo_home() { + let fixture = CaptureFixture::new(); + let path = &fixture.captured.request.cargo_metadata; + let mut metadata: Value = + serde_json::from_slice(&fs::read(path).expect("should read retained metadata")) + .expect("should parse retained metadata"); + metadata["packages"] + .as_array_mut() + .expect("should list resolved packages") + .push(json!({ + "id": "registry+https://example.com/index#example@1.0.0", + "name": "example", + "manifest_path": "/home/example/.cargo/registry/src/index/example-1.0.0/Cargo.toml" + })); + fs::write( + path, + serde_json::to_vec(&metadata).expect("should encode metadata"), + ) + .expect("should retain the changed metadata"); + let Err(error) = finish( + &fixture.captured, + &fixture.initial, + &fixture.pins, + &fixture.tools(), + ) else { + panic!("should reject a package resolved outside the private Cargo home"); + }; + assert!( + format!("{error:?}").contains("outside the checkout and private Cargo home"), + "should reach the dependency source rule while recording" + ); +} diff --git a/crates/trusted-server-release/src/ordinary/inputs/tests.rs b/crates/trusted-server-release/src/ordinary/inputs/tests.rs new file mode 100644 index 0000000000..abd830f052 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/inputs/tests.rs @@ -0,0 +1,337 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use super::{javascript_identity, source_identity}; + +pub(in crate::ordinary) struct Fixture { + _temporary: tempfile::TempDir, + pub(in crate::ordinary) checkout: PathBuf, + pub(super) target: PathBuf, +} + +impl Fixture { + pub(in crate::ordinary) fn new() -> Self { + let temporary = tempfile::tempdir().expect("should create source fixture"); + let outer = fs::canonicalize(temporary.path()).expect("should resolve fixture root"); + let checkout = outer.join("workspace/ordinary-canonical-source"); + let target = outer.join("temp/ordinary-fastly-canonical-target"); + fs::create_dir_all(&checkout).expect("should create source root"); + fs::create_dir_all(&target).expect("should create target root"); + for file in [ + "scripts/release/phase1-build.sh", + "scripts/release/fastly.toml", + ".cargo/config.toml", + "Cargo.toml", + "rust-toolchain.toml", + ".tool-versions", + "Cargo.lock", + "crates/trusted-server-js/lib/package-lock.json", + ] { + let path = checkout.join(file); + fs::create_dir_all(path.parent().expect("should have a parent")) + .expect("should create fixed input directory"); + fs::write(path, b"example source input\n").expect("should write fixed input"); + } + fs::write(checkout.join(".cargo/config.toml"), b"[alias]\n") + .expect("should retain valid Cargo configuration"); + Self { + _temporary: temporary, + checkout, + target, + } + } + + pub(super) fn private_js(&self) -> (Value, Vec) { + let js = self.checkout.join("crates/trusted-server-js"); + fs::write(js.join("Cargo.toml"), b"example package").expect("should write JS manifest"); + for module in ["core", "integrations/example"] { + let path = js.join("lib/src").join(module); + fs::create_dir_all(&path).expect("should create module directory"); + fs::write(path.join("index.ts"), b"export {};").expect("should write module entry"); + } + fs::create_dir_all(self.output()).expect("should create private JS output"); + for file in ["tsjs-core.js", "tsjs-example.js"] { + fs::write(self.output().join(file), file.as_bytes()) + .expect("should write distinct bundle bytes"); + } + let id = format!("path+file://{}#0.1.0", js.display()); + let metadata = json!({"workspace_root": self.checkout, "workspace_members": [id], "packages":[{ + "id":id,"name":"trusted-server-js","manifest_path":js.join("Cargo.toml") + }]}); + let messages = vec![ + json!({"reason":"build-script-executed","package_id":id,"out_dir":self.output().parent().expect("should have output parent"),"env":[]}), + json!({"reason":"build-finished","success":true}), + ]; + (metadata, messages) + } + + fn output(&self) -> PathBuf { + self.target + .join("wasm32-wasip1/release/build/trusted-server-js-example/out/tsjs-dist") + } + + fn js_identity( + &self, + metadata: &Value, + messages: &[Value], + ) -> Result> { + let metadata = serde_json::to_vec(metadata).expect("should encode Cargo metadata"); + let messages: Vec = messages + .iter() + .flat_map(|message| { + let mut bytes = serde_json::to_vec(message).expect("should encode Cargo message"); + bytes.push(b'\n'); + bytes + }) + .collect(); + javascript_identity(&self.checkout, &self.target, &metadata, &messages) + } +} + +#[test] +fn source_recipe_identity_is_canonical_and_independent_of_runner_roots() { + let first = Fixture::new(); + let second = Fixture::new(); + let identity = source_identity(&first.checkout, &"a".repeat(40)) + .expect("should measure fixed source inputs"); + assert_eq!( + identity, + source_identity(&second.checkout, &"a".repeat(40)).expect("should measure the other root"), + "should omit deliberate runner path differences from recipe identity" + ); + assert_eq!( + identity.recipe_sha256.len(), + 64, + "should produce a SHA-256 recipe identity" + ); +} + +#[test] +fn source_recipe_and_lock_identities_change_only_with_their_actual_inputs() { + let fixture = Fixture::new(); + let source = "a".repeat(40); + let first = source_identity(&fixture.checkout, &source).expect("should measure baseline"); + fs::write(fixture.checkout.join("Cargo.lock"), b"changed lock").expect("should change lock"); + let lock = source_identity(&fixture.checkout, &source).expect("should remeasure lock"); + assert_eq!( + first.recipe_sha256, lock.recipe_sha256, + "should keep lock identity separately visible" + ); + assert_ne!( + first.cargo_lock_sha256, lock.cargo_lock_sha256, + "should hash actual lock bytes" + ); + fs::write( + fixture.checkout.join("scripts/release/phase1-build.sh"), + b"changed recipe", + ) + .expect("should change recipe"); + let recipe = source_identity(&fixture.checkout, &source).expect("should remeasure recipe"); + assert_ne!( + lock.recipe_sha256, recipe.recipe_sha256, + "should hash the actual source-owned producer" + ); + assert_ne!( + recipe.recipe_sha256, + source_identity(&fixture.checkout, &"b".repeat(40)) + .expect("should bind another source") + .recipe_sha256, + "should bind the declared workflow source commit" + ); +} + +#[test] +fn source_identity_rejects_invalid_commits_and_aliased_or_hardlinked_inputs() { + let fixture = Fixture::new(); + assert!( + source_identity(&fixture.checkout, "main").is_err(), + "should reject symbolic source identity" + ); + let path = fixture.checkout.join("Cargo.lock"); + fs::hard_link(&path, fixture.checkout.join("other-lock")).expect("should create test hardlink"); + assert!( + source_identity(&fixture.checkout, &"a".repeat(40)).is_err(), + "should reject shared source input identity" + ); + fs::remove_file(&path).expect("should remove first link"); + symlink("other-lock", &path).expect("should create input alias"); + assert!( + source_identity(&fixture.checkout, &"a".repeat(40)).is_err(), + "should reject even an internal source alias" + ); +} + +#[test] +fn measures_consumed_private_js_without_hashing_checkout_dependent_package_ids() { + let first = Fixture::new(); + let second = Fixture::new(); + let (metadata, messages) = first.private_js(); + let (other_metadata, other_messages) = second.private_js(); + let identity = first + .js_identity(&metadata, &messages) + .expect("should measure actual private JS"); + assert_eq!( + identity, + second + .js_identity(&other_metadata, &other_messages) + .expect("should measure the other private root"), + "should hash sorted bundle facts rather than local Cargo IDs" + ); + fs::write(first.output().join("tsjs-example.js"), b"changed") + .expect("should mutate actual consumed output"); + assert_ne!( + identity, + first + .js_identity(&metadata, &messages) + .expect("should remeasure private bytes"), + "should identify actual consumed output changes" + ); +} + +#[test] +fn rejects_missing_duplicate_foreign_and_host_side_js_build_records() { + let fixture = Fixture::new(); + let (metadata, messages) = fixture.private_js(); + for changed in [ + vec![messages[1].clone()], + vec![ + messages[0].clone(), + messages[0].clone(), + messages[1].clone(), + ], + vec![ + json!({"reason":"build-script-executed","package_id":"other","out_dir":fixture.output().parent(),"env":[]}), + messages[1].clone(), + ], + vec![ + json!({"reason":"build-script-executed","package_id":messages[0]["package_id"],"out_dir":fixture.target.join("release/build/trusted-server-js-example/out"),"env":[]}), + messages[1].clone(), + ], + ] { + assert!( + fixture.js_identity(&metadata, &changed).is_err(), + "should reject an ambiguous or non-target JS result" + ); + } + let mut duplicate = metadata.clone(); + duplicate["packages"] + .as_array_mut() + .expect("should list packages") + .push(metadata["packages"][0].clone()); + assert!( + fixture.js_identity(&duplicate, &messages).is_err(), + "should reject ambiguous package IDs" + ); + for (field, value) in [ + ("/workspace_root", json!("/example/foreign")), + ( + "/packages/0/manifest_path", + json!("/example/foreign/Cargo.toml"), + ), + ("/workspace_members", json!([])), + ] { + let mut changed = metadata.clone(); + *changed + .pointer_mut(field) + .expect("should locate metadata fixture") = value; + assert!( + fixture.js_identity(&changed, &messages).is_err(), + "should reject foreign or unowned metadata at {field}" + ); + } + let mut prebuilt = messages.clone(); + prebuilt[0]["env"] = json!([["TRUSTED_SERVER__TSJS_INVENTORY_SHA256", "a".repeat(64)]]); + assert!( + fixture.js_identity(&metadata, &prebuilt).is_err(), + "should reject a checked-prebuilt result in the ordinary fresh-build profile" + ); +} + +#[test] +fn rejects_private_output_directory_and_ancestor_aliases() { + for ancestor in [false, true] { + let fixture = Fixture::new(); + let (metadata, messages) = fixture.private_js(); + let original = if ancestor { + fixture + .output() + .parent() + .expect("should have out parent") + .parent() + .expect("should have package parent") + .to_owned() + } else { + fixture.output() + }; + let retained = original.with_extension("retained"); + fs::rename(&original, &retained) + .expect("should retain actual output under a different identity"); + symlink(&retained, &original).expect("should alias the output directory"); + assert!( + fixture.js_identity(&metadata, &messages).is_err(), + "should reject an aliased private output directory or ancestor" + ); + } +} + +#[test] +fn rejects_casefold_collisions_with_the_source_core_module() { + let fixture = Fixture::new(); + let (metadata, messages) = fixture.private_js(); + let path = fixture + .checkout + .join("crates/trusted-server-js/lib/src/integrations/Core"); + fs::create_dir(&path).expect("should create colliding integration directory"); + fs::write(path.join("index.ts"), b"export {};").expect("should write collision entry"); + assert!( + fixture.js_identity(&metadata, &messages).is_err(), + "should reject a casefold collision with the reserved core module on every filesystem" + ); +} + +#[test] +fn rejects_failed_or_truncated_cargo_completion() { + let fixture = Fixture::new(); + let (metadata, mut messages) = fixture.private_js(); + messages[1]["success"] = json!(false); + assert!( + fixture.js_identity(&metadata, &messages).is_err(), + "should require the actual successful Cargo completion" + ); + let bytes = serde_json::to_vec(&metadata).expect("should encode metadata"); + assert!( + javascript_identity(&fixture.checkout, &fixture.target, &bytes, b"{}").is_err(), + "should reject a truncated or malformed Cargo stream" + ); +} + +#[test] +fn rejects_missing_extra_empty_aliased_and_hardlinked_private_bundles() { + for mutation in 0..5 { + let fixture = Fixture::new(); + let (metadata, messages) = fixture.private_js(); + let file = fixture.output().join("tsjs-example.js"); + match mutation { + 0 => fs::remove_file(&file).expect("should remove bundle"), + 1 => fs::write(fixture.output().join("extra.txt"), b"extra") + .expect("should add unexpected output"), + 2 => fs::write(&file, b"").expect("should empty bundle"), + 3 => { + fs::remove_file(&file).expect("should remove bundle"); + symlink("tsjs-core.js", &file).expect("should alias bundle"); + } + _ => { + fs::remove_file(&file).expect("should remove bundle"); + fs::hard_link(fixture.output().join("tsjs-core.js"), &file) + .expect("should link bundle"); + } + } + assert!( + fixture.js_identity(&metadata, &messages).is_err(), + "should reject private JS mutation {mutation}" + ); + } +} diff --git a/crates/trusted-server-release/src/ordinary/normalization.rs b/crates/trusted-server-release/src/ordinary/normalization.rs new file mode 100644 index 0000000000..1beaf83114 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/normalization.rs @@ -0,0 +1,314 @@ +//! Ordinary Fastly path remapping, without controlled recipe qualification. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs; +use std::os::unix::fs::MetadataExt as _; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use wasmparser::{Parser, Payload}; + +use super::cargo as recipe; +use super::{require, root}; +use crate::documents::{self, Role}; +use crate::error::ReleaseError; + +/// Closed six-map argument plan; it constructs no qualified recipe. +pub(super) struct Flags { + /// The exact pair passed to Cargo. + pub(super) arguments: Vec, + /// Canonical UTF-8 argument JSON including its trailing LF. + pub(super) bytes: Vec, + /// Digest of those retained bytes. + pub(super) sha256: String, + roots: [String; 6], +} + +fn rejected() -> Report { + Report::new(ReleaseError::Document).attach("ordinary path normalization inputs rejected") +} + +/// Keeps the specific Cargo-configuration cause beneath the generic rejection. +fn rejected_because(cause: Report) -> Report { + cause + .change_context(ReleaseError::Document) + .attach("ordinary path normalization inputs rejected") +} + +fn text(path: &Path) -> Result> { + path.to_str() + .filter(|value| { + root(value) + && !value.contains(['=', ':', '"', '\\']) + && !value.chars().any(char::is_control) + }) + .map(str::to_owned) + .ok_or_else(rejected) +} + +/// The private Cargo home a role must build with, beside its target root. +pub(super) fn cargo_home(target: &Path, role: &str) -> Option { + Some(target.parent()?.join(format!("ordinary-{role}-cargo-home"))) +} + +/// Derives identical source-owned maps from either role's physical roots. +/// +/// # Errors +/// +/// Rejects unapproved roles, layout names, aliases in text, overlaps and unsafe encoding. +pub(super) fn plan( + checkout: &Path, + target: &Path, + role: &str, +) -> Result> { + require( + matches!(role, "canonical" | "reproduction"), + "ordinary normalization role is invalid", + )?; + text(checkout)?; + text(target)?; + require( + checkout + .file_name() + .is_some_and(|name| name == format!("ordinary-{role}-source").as_str()) + && target + .file_name() + .is_some_and(|name| name == format!("ordinary-fastly-{role}-target").as_str()), + "ordinary normalization roots do not match their source-owned role layout", + )?; + let workspace = checkout.parent().ok_or_else(rejected)?; + let temporary = target.parent().ok_or_else(rejected)?; + let roots = [ + text(&workspace.join("ordinary-canonical-source"))?, + text(&workspace.join("ordinary-reproduction-source"))?, + text(&temporary.join("ordinary-fastly-canonical-target"))?, + text(&temporary.join("ordinary-fastly-reproduction-target"))?, + text(&temporary.join("ordinary-canonical-cargo-home"))?, + text(&temporary.join("ordinary-reproduction-cargo-home"))?, + ]; + for (index, left) in roots.iter().enumerate() { + for right in roots.iter().skip(index + 1) { + require( + !Path::new(left).starts_with(right) && !Path::new(right).starts_with(left), + "ordinary normalization roots overlap", + )?; + } + } + let destinations = [ + recipe::REMAPPED_CHECKOUT, + recipe::REMAPPED_CHECKOUT, + recipe::REMAPPED_TARGET, + recipe::REMAPPED_TARGET, + recipe::REMAPPED_CARGO_HOME, + recipe::REMAPPED_CARGO_HOME, + ]; + let flags: Vec<_> = roots + .iter() + .zip(destinations) + .map(|(from, to)| format!("--remap-path-prefix={from}={to}")) + .collect(); + let arguments = recipe::rustflags_arguments("wasm32-wasip1", &flags)?; + let bytes = documents::canonical_json(&json!(arguments), Role::Metadata)?; + let sha256 = hex::encode(Sha256::digest(&bytes)); + Ok(Flags { + arguments, + bytes, + sha256, + roots, + }) +} + +/// Checks real runner layout and configuration before any target build starts. +/// +/// # Errors +/// +/// Rejects aliases, different owners, nonempty targets and competing Cargo flags. +pub(super) fn prepare( + checkout: &Path, + target: &Path, + role: &str, + inherited: &[(OsString, OsString)], +) -> Result> { + let flags = plan(checkout, target, role)?; + let home = cargo_home(target, role).ok_or_else(rejected)?; + require( + inherited + .iter() + .any(|(name, value)| name == "CARGO_HOME" && value.as_os_str() == home.as_os_str()), + "ordinary build must use its role's private Cargo home", + )?; + // SAFETY: geteuid only reads the process identity and has no memory preconditions. + let owner = unsafe { libc::geteuid() }; + for path in &flags.roots { + match fs::symlink_metadata(path) { + Ok(metadata) => require( + metadata.is_dir() + && metadata.uid() == owner + && fs::canonicalize(path).is_ok_and(|resolved| resolved == Path::new(path)), + "ordinary normalization root is aliased or not owned by this runner", + )?, + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && Path::new(path) != checkout + && Path::new(path) != target + && Path::new(path) != home => {} + _ => return Err(rejected()), + } + } + require( + fs::read_dir(target) + .map_err(|_| rejected())? + .next() + .is_none(), + "ordinary normalization target must be fresh and empty", + )?; + validate_configuration(checkout, inherited)?; + Ok(flags) +} + +fn flag_override(name: &str) -> bool { + matches!( + name, + "RUSTFLAGS" | "CARGO_ENCODED_RUSTFLAGS" | "CARGO_BUILD_RUSTFLAGS" + ) || (name.starts_with("CARGO_TARGET_") && name.ends_with("_RUSTFLAGS")) +} + +fn absent_layer(path: &Path) -> Result<(), Report> { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + _ => Err(rejected() + .attach("ordinary normalization refuses an unhandled Cargo configuration layer")), + } +} + +/// Refuses configuration that could replace or compete with the target remap. +/// +/// # Errors +/// +/// Rejects ambient overrides, extensionless config, build flags, competing maps, +/// or unsupported target flag representations. Reviewed arrays are never changed. +pub(super) fn validate_configuration( + checkout: &Path, + inherited: &[(OsString, OsString)], +) -> Result<(), Report> { + require( + !inherited + .iter() + .any(|(name, _)| name.to_str().is_some_and(flag_override)), + "ordinary normalization refuses ambient rustflags", + )?; + for ancestor in checkout.ancestors().skip(1) { + for file in ["config", "config.toml"] { + absent_layer(&ancestor.join(".cargo").join(file))?; + } + } + let cargo_home = inherited + .iter() + .find(|(name, _)| name == "CARGO_HOME") + .map(|(_, value)| std::path::PathBuf::from(value)) + .or_else(|| { + inherited + .iter() + .find(|(name, _)| name == "HOME") + .map(|(_, value)| std::path::PathBuf::from(value).join(".cargo")) + }); + if let Some(home) = cargo_home { + require(home.is_absolute(), "ordinary Cargo home must be absolute")?; + for file in ["config", "config.toml"] { + let candidate = home.join(file); + if candidate != checkout.join(".cargo/config.toml") { + absent_layer(&candidate)?; + } + } + } + recipe::check_no_extensionless_config(checkout, false).map_err(rejected_because)?; + let bytes = documents::read_bounded(&checkout.join(".cargo/config.toml"), Role::Metadata)?; + recipe::check_cargo_config(&bytes.bytes).map_err(rejected_because)?; + let table: toml::Value = + toml::from_str(std::str::from_utf8(&bytes.bytes).map_err(|_| rejected())?) + .map_err(|_| rejected())?; + require( + table.get("include").is_none(), + "ordinary normalization refuses included Cargo configuration layers", + )?; + if let Some(environment) = table.get("env") { + require( + !environment + .as_table() + .ok_or_else(rejected)? + .keys() + .any(|key| flag_override(key)), + "ordinary normalization refuses configured flag overrides", + )?; + } + if let Some(targets) = table.get("target") { + for target in targets.as_table().ok_or_else(rejected)?.values() { + let target = target.as_table().ok_or_else(rejected)?; + if let Some(flags) = target.get("rustflags") { + for flag in flags.as_array().ok_or_else(rejected)? { + let flag = flag.as_str().ok_or_else(rejected)?; + require( + !flag.starts_with("--remap-path-prefix"), + "ordinary normalization refuses preexisting remap options", + )?; + } + } + } + } + Ok(()) +} + +/// Checks complete raw bytes and retained debug/name sections after compilation. +/// +/// # Errors +/// +/// Rejects raw physical roots, malformed Wasm, missing/duplicate debug sections, +/// or absent normalized source identity. No section is changed or excluded. +pub(super) fn scan(bytes: &[u8], flags: &Flags) -> Result<(), Report> { + require( + bytes.len() as u64 <= Role::Executable.limit() && Parser::is_core_wasm(bytes), + "ordinary normalized artifact is not bounded core Wasm", + )?; + let forbidden: Vec<_> = flags.roots.iter().map(String::as_str).collect(); + recipe::reject_execution_prefixes(bytes, &forbidden)?; + let mut sections = BTreeSet::new(); + let mut mapped_source = false; + for payload in Parser::new(0).parse_all(bytes) { + if let Payload::CustomSection(section) = payload.map_err(|_| rejected())? + && matches!( + section.name(), + ".debug_info" | ".debug_str" | ".debug_line" | "name" + ) + { + require( + !section.data().is_empty() && sections.insert(section.name()), + "ordinary normalized debug/name sections are empty or duplicated", + )?; + if section.name() == ".debug_str" { + let prefix = format!("{}/", recipe::REMAPPED_CHECKOUT); + mapped_source = section.data().split(|byte| *byte == 0).any(|text| { + text == recipe::REMAPPED_CHECKOUT.as_bytes() + || text.starts_with(prefix.as_bytes()) + }); + } + } + } + require( + sections.len() == 4 && mapped_source, + "ordinary normalized debug/source identity is unproven", + ) +} + +/// Stable semantic policy, deliberately excluding physical runner paths. +pub(super) fn policy() -> Value { + json!({"kind":"ordinary-rust-path-remap-v2","target":"wasm32-wasip1", "roles":["canonical","reproduction"],"source_prefix":recipe::REMAPPED_CHECKOUT,"target_prefix":recipe::REMAPPED_TARGET,"cargo_home_prefix":recipe::REMAPPED_CARGO_HOME}) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +pub(super) const TEST_WASM: &[u8] = b"\0asm\x01\0\0\0\0\x0d\x0b.debug_info\0\0\x1b\x0a.debug_str/trusted-server\0\0\x0d\x0b.debug_line\0\0\x06\x04name\0"; diff --git a/crates/trusted-server-release/src/ordinary/normalization/tests.rs b/crates/trusted-server-release/src/ordinary/normalization/tests.rs new file mode 100644 index 0000000000..109a061667 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/normalization/tests.rs @@ -0,0 +1,386 @@ +use std::ffi::OsString; +use std::fs; +use std::path::Path; + +use serde_json::json; + +use super::{plan, validate_configuration}; + +#[test] +fn both_roles_have_identical_six_map_arguments_and_retained_canonical_bytes() { + let a = plan( + Path::new("/example/workspace/ordinary-canonical-source"), + Path::new("/example/temp/ordinary-fastly-canonical-target"), + "canonical", + ) + .expect("should plan the closed canonical layout"); + let b = plan( + Path::new("/example/workspace/ordinary-reproduction-source"), + Path::new("/example/temp/ordinary-fastly-reproduction-target"), + "reproduction", + ) + .expect("should plan the closed reproduction layout"); + assert_eq!( + a.arguments, b.arguments, + "should pass identical raw configuration arguments to both builds" + ); + assert_eq!( + a.arguments[0], "--config", + "should merge target flags through Cargo configuration" + ); + let document: toml::Value = + toml::from_str(&a.arguments[1]).expect("should encode valid quoted TOML"); + assert_eq!( + document["target"]["wasm32-wasip1"]["rustflags"] + .as_array() + .expect("should list the closed remaps") + .len(), + 6, + "should include both source, target and Cargo home mappings" + ); + assert_eq!( + a.bytes, + crate::documents::canonical_json(&json!(a.arguments), crate::documents::Role::Metadata) + .expect("should encode the actual argument pair"), + "should hash exact canonical UTF-8 arguments with LF" + ); + assert_eq!( + a.sha256, b.sha256, + "should retain one shared raw-argument digest within the run" + ); + assert!( + a.arguments[1].contains("=/trusted-server"), + "should reuse the reviewed source prefix" + ); + assert!( + a.arguments[1].contains("=/cargo-target"), + "should reuse the reviewed target prefix" + ); + for role in ["canonical", "reproduction"] { + assert!( + a.arguments[1].contains(&format!( + "/example/temp/ordinary-{role}-cargo-home=/cargo-home" + )), + "should remap the {role} private Cargo home" + ); + } +} + +#[test] +fn scan_rejects_a_raw_private_cargo_home() { + let flags = plan( + Path::new("/example/workspace/ordinary-canonical-source"), + Path::new("/example/temp/ordinary-fastly-canonical-target"), + "canonical", + ) + .expect("should plan the closed canonical layout"); + for home in [ + "/example/temp/ordinary-canonical-cargo-home", + "/example/temp/ordinary-reproduction-cargo-home", + ] { + let mut bytes = crate::ordinary::normalization::TEST_WASM.to_vec(); + let path = format!("{home}/registry/src/example/lib.rs"); + bytes.extend_from_slice(&[0, u8::try_from(path.len() + 4).expect("should fit"), 3]); + bytes.extend_from_slice(b"raw"); + bytes.extend_from_slice(path.as_bytes()); + assert!( + super::scan(&bytes, &flags).is_err(), + "should reject an unremapped Cargo home {home}" + ); + } +} + +#[test] +fn rejects_wrong_roles_unsafe_prefixes_and_overlapping_layouts() { + for (source, target, role) in [ + ( + "/example/workspace/source", + "/example/temp/ordinary-fastly-canonical-target", + "canonical", + ), + ( + "/example/workspace/ordinary-canonical-source", + "/example/temp/target", + "canonical", + ), + ( + "/example/workspace/ordinary-canonical-source", + "/example/temp/ordinary-fastly-canonical-target", + "other", + ), + ( + "/example/bad=prefix/ordinary-canonical-source", + "/example/temp/ordinary-fastly-canonical-target", + "canonical", + ), + ( + "/example/workspace/ordinary-canonical-source", + "/example/workspace/ordinary-canonical-source/ordinary-fastly-canonical-target", + "canonical", + ), + ] { + assert!( + plan(Path::new(source), Path::new(target), role).is_err(), + "should reject unsupported role/layout or ambiguous remapping text" + ); + } +} + +#[test] +fn preserves_supported_array_flags_and_rejects_competing_or_unhandled_configuration() { + let temporary = tempfile::tempdir().expect("should create configuration fixture"); + let root = fs::canonicalize(temporary.path()).expect("should resolve configuration fixture"); + fs::create_dir(root.join(".cargo")) + .expect("should create source Cargo configuration directory"); + let path = root.join(".cargo/config.toml"); + let valid = "[target.wasm32-wasip1]\nrustflags = [\"-Copt-level=3\"]\n"; + fs::write(&path, valid).expect("should retain reviewed array flags"); + validate_configuration(&root, &[]).expect("should preserve configured target arrays"); + assert_eq!( + fs::read_to_string(&path).expect("should retain source configuration"), + valid, + "should never rewrite configured flags" + ); + for invalid in [ + "include = [\"other.toml\"]\n", + "[build]\nrustflags=[]\n", + "[target.wasm32-wasip1]\nrustflags=\"-Copt-level=3\"\n", + "[target.wasm32-wasip1]\nrustflags=[\"--remap-path-prefix=/a=/b\"]\n", + "[target.'cfg(unix)']\nrustflags=[\"--remap-path-prefix=/a=/b\"]\n", + "[env]\nRUSTFLAGS=\"-Copt-level=0\"\n", + ] { + fs::write(&path, invalid).expect("should write rejected configuration"); + assert!( + validate_configuration(&root, &[]).is_err(), + "should refuse unhandled or competing configured flags" + ); + } + fs::write(&path, valid).expect("should restore valid source configuration"); + for name in [ + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_TARGET_WASM32_WASIP1_RUSTFLAGS", + ] { + assert!( + validate_configuration(&root, &[(OsString::from(name), OsString::new())]).is_err(), + "should reject any ambient flag presence" + ); + } + fs::write(root.join(".cargo/config"), b"") + .expect("should add an unreviewed extensionless layer"); + assert!( + validate_configuration(&root, &[]).is_err(), + "should reject the extensionless source configuration" + ); +} + +#[test] +fn rejects_unreviewed_ancestor_and_cargo_home_config_layers() { + let temporary = tempfile::tempdir().expect("should create configuration parent"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve configuration parent"); + let source = parent.join("project"); + fs::create_dir_all(source.join(".cargo")).expect("should create source configuration root"); + fs::write(source.join(".cargo/config.toml"), b"[alias]\n") + .expect("should write source configuration"); + let home = parent.join("cargo-home"); + fs::create_dir(&home).expect("should create explicit Cargo home"); + fs::write(home.join("config.toml"), b"[build]\nrustflags=[]\n") + .expect("should retain an unreviewed layer"); + assert!( + validate_configuration( + &source, + &[(OsString::from("CARGO_HOME"), home.into_os_string())] + ) + .is_err(), + "should not silently supersede global flags" + ); + fs::create_dir(parent.join(".cargo")) + .expect("should create an ancestor configuration directory"); + fs::write(parent.join(".cargo/config.toml"), b"[alias]\n") + .expect("should create an inherited layer"); + assert!( + validate_configuration(&source, &[]).is_err(), + "should refuse unknown ancestor layers" + ); +} + +fn wasm(sections: &[(&str, &[u8])]) -> Vec { + let mut bytes = b"\0asm\x01\0\0\0".to_vec(); + for (name, data) in sections { + assert!( + name.len() + data.len() + 1 < 128, + "should keep fixture lengths single-byte" + ); + bytes.extend([0, (name.len() + data.len() + 1) as u8, name.len() as u8]); + bytes.extend(name.as_bytes()); + bytes.extend(*data); + } + bytes +} + +#[test] +fn scan_requires_debug_sections_and_rejects_raw_roots_without_rewriting_bytes() { + let flags = plan( + Path::new("/example/workspace/ordinary-canonical-source"), + Path::new("/example/temp/ordinary-fastly-canonical-target"), + "canonical", + ) + .expect("should plan fixed maps"); + let sections: [(&str, &[u8]); 4] = [ + (".debug_info", b"fixture"), + (".debug_str", b"/trusted-server\0"), + (".debug_line", b"fixture"), + ("name", b"fixture"), + ]; + let valid = wasm(§ions); + super::scan(&valid, &flags).expect("should retain debug sections and logical source identity"); + let false_prefix = wasm(&[ + sections[0], + (".debug_str", b"x/trusted-server\0"), + sections[2], + sections[3], + ]); + assert!( + super::scan(&false_prefix, &flags).is_err(), + "should require a complete logical source string rather than a matching substring" + ); + for invalid in [ + b"\0asm\x01\0\0\0".to_vec(), + wasm(§ions[..3]), + wasm(&[ + sections[0], + sections[0], + sections[1], + sections[2], + sections[3], + ]), + wasm(&[(".debug_info", b""), sections[1], sections[2], sections[3]]), + wasm(&[ + sections[0], + (".debug_str", b"/unmapped-source\0"), + sections[2], + sections[3], + ]), + ] { + assert!( + super::scan(&invalid, &flags).is_err(), + "should reject missing, duplicate, empty or unmapped debug identity" + ); + } + for prefix in &flags.roots { + let contaminated = wasm(&[ + sections[0], + sections[1], + sections[2], + sections[3], + ("raw", format!("{prefix}/src.rs").as_bytes()), + ]); + assert!( + super::scan(&contaminated, &flags).is_err(), + "should scan all bytes including unrelated custom sections" + ); + } + let mut truncated = valid.clone(); + truncated.pop(); + assert!( + super::scan(&truncated, &flags).is_err(), + "should reject truncated section data" + ); +} + +#[test] +fn preparation_requires_canonical_owned_roots_and_a_fresh_target() { + let temporary = tempfile::tempdir().expect("should create layout fixture"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve fixture parent"); + let source = parent.join("workspace/ordinary-canonical-source"); + let target = parent.join("temp/ordinary-fastly-canonical-target"); + fs::create_dir_all(source.join(".cargo")).expect("should create source configuration"); + fs::create_dir_all(&target).expect("should create fresh target"); + fs::write(source.join(".cargo/config.toml"), b"[alias]\n") + .expect("should retain valid source configuration"); + let home = parent.join("temp/ordinary-canonical-cargo-home"); + let inherited = [(OsString::from("CARGO_HOME"), OsString::from(&home))]; + assert!( + super::prepare(&source, &target, "canonical", &inherited).is_err(), + "should refuse a missing private Cargo home" + ); + fs::create_dir(&home).expect("should create the private Cargo home"); + for wrong in [ + Vec::new(), + vec![( + OsString::from("CARGO_HOME"), + OsString::from(parent.join("home/.cargo")), + )], + vec![( + OsString::from("CARGO_HOME"), + OsString::from(parent.join("temp/ordinary-reproduction-cargo-home")), + )], + // Cargo keeps these spellings verbatim in embedded paths, so only the + // exact private home is accepted. + vec![( + OsString::from("CARGO_HOME"), + OsString::from(format!( + "{}/temp/./ordinary-canonical-cargo-home", + parent.display() + )), + )], + vec![( + OsString::from("CARGO_HOME"), + OsString::from(format!( + "{}/temp//ordinary-canonical-cargo-home/", + parent.display() + )), + )], + ] { + assert!( + super::prepare(&source, &target, "canonical", &wrong).is_err(), + "should refuse a build without its role's private Cargo home" + ); + } + super::prepare(&source, &target, "canonical", &inherited) + .expect("should accept fresh canonical layout"); + let output = parent.join("arguments"); + let argument = crate::ordinary::commands::prepare_rustflags( + &source, + &target, + "canonical", + &output, + &inherited, + ) + .expect("should seal arguments before the build"); + let expected = + plan(&source, &target, "canonical").expect("should derive fixed expected arguments"); + assert_eq!( + argument, expected.arguments[1], + "should return precisely the retained Cargo config argument" + ); + assert_eq!( + fs::read(output.join("rustflags.json")).expect("should retain argument bytes"), + expected.bytes, + "should seal canonical argument bytes before Cargo runs" + ); + assert!( + crate::ordinary::commands::prepare_rustflags( + &source, + &target, + "canonical", + &output, + &inherited + ) + .is_err(), + "should refuse to reuse argument output" + ); + fs::write(target.join("stale"), b"stale").expect("should contaminate target"); + assert!( + super::prepare(&source, &target, "canonical", &inherited).is_err(), + "should refuse reused target roots" + ); + fs::remove_file(target.join("stale")).expect("should restore empty target"); + let peer = parent.join("workspace/ordinary-reproduction-source"); + std::os::unix::fs::symlink(&source, peer).expect("should create forbidden peer alias"); + assert!( + super::prepare(&source, &target, "canonical", &inherited).is_err(), + "should refuse an existing aliased peer mapping" + ); +} diff --git a/crates/trusted-server-release/src/ordinary/output.rs b/crates/trusted-server-release/src/ordinary/output.rs new file mode 100644 index 0000000000..6b1b41ebb4 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/output.rs @@ -0,0 +1,256 @@ +//! Fresh atomic ordinary outputs; no index or qualification evidence writer. + +use std::ffi::CString; +use std::fs::{self, OpenOptions}; +use std::io::Write as _; +use std::os::fd::{AsRawFd as _, FromRawFd as _}; +use std::os::unix::fs::{ + DirBuilderExt as _, MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _, +}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +use super::transfer::Checked; +use super::{Observation, require, root}; +use crate::documents::{self, Role}; +use crate::error::ReleaseError; + +const COMPANION: &str = "trusted-server-fastly.wasm.rebuild-check.json"; + +/// Retains CI-only diagnostic JSON in a distinct private directory. +/// +/// # Errors +/// +/// Rejects unsafe or reused paths and any failed atomic write or readback. +pub(super) fn diagnostic(directory: &Path, value: &Value) -> Result> { + persist( + directory, + "ordinary-comparison-diagnostic.json", + value, + Role::Metadata, + ) +} + +/// Retains the exact target-scoped Cargo argument pair before compilation. +/// +/// # Errors +/// +/// Rejects reused or aliased output paths and any failed atomic write/readback. +pub(super) fn rustflags( + directory: &Path, + arguments: &[String], +) -> Result> { + persist( + directory, + "rustflags.json", + &serde_json::json!(arguments), + Role::Metadata, + ) +} + +/// Seals one matched ordinary comparison under the permanent companion name. +/// +/// # Errors +/// +/// Rejects inconsistent retained bytes, noncanonical parents, reused roots, +/// unsafe paths or any failure to persist the exact private result atomically. +pub(super) fn companion( + checked: &Checked, + directory: &Path, +) -> Result> { + let value = checked.receipt(); + let (binary, spdx, package) = checked.inputs(); + require( + value["outcome"] == "matched" + && value["release_qualification"] == false + && value["canonical_sha256"] == hex::encode(Sha256::digest(binary)) + && value["canonical"]["size"].as_u64() == Some(binary.len() as u64) + && value["canonical"]["spdx_sha256"] == hex::encode(Sha256::digest(spdx)) + && value["package"]["canonical_sha256"] == hex::encode(Sha256::digest(package)) + && value["package"]["size"].as_u64() == Some(package.len() as u64), + "ordinary retained canonical inputs changed before companion handoff", + )?; + persist(directory, COMPANION, &value, Role::Companion) +} + +/// Retains the unpublished ordinary observation under its fixed transfer name. +/// +/// # Errors +/// +/// Rejects a reused/aliased output root or a failed atomic private write. +pub(super) fn observation( + observed: &Observation, + directory: &Path, +) -> Result> { + persist( + directory, + "fastly-observation.json", + observed.value(), + Role::Metadata, + ) +} + +fn invalid() -> Report { + Report::new(ReleaseError::Document).attach("ordinary fresh output cannot be sealed") +} + +fn same_directory(held: &fs::File, path: &Path) -> Result<(), Report> { + let before = held.metadata().map_err(|_| invalid())?; + let now = fs::symlink_metadata(path).map_err(|_| invalid())?; + require( + now.is_dir() + && (before.dev(), before.ino(), before.uid()) == (now.dev(), now.ino(), now.uid()), + "ordinary output directory identity changed", + ) +} + +fn directory(path: &Path) -> Result> { + OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + .map_err(|_| invalid()) +} + +fn persist( + output: &Path, + name: &str, + value: &Value, + role: Role, +) -> Result> { + persist_with_checkpoints(output, name, value, role, &|_, _| Ok(())) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum WriteCheckpoint { + Persisted, + Verified, +} + +type WriteCheck<'a> = dyn Fn(WriteCheckpoint, &Path) -> Result<(), Report> + 'a; + +fn persist_with_checkpoints( + output: &Path, + name: &str, + value: &Value, + role: Role, + checkpoint: &WriteCheck<'_>, +) -> Result> { + let bytes = documents::canonical_json(value, role)?; + let parent = output.parent().ok_or_else(invalid)?; + require( + output.to_str().is_some_and(root) + && fs::canonicalize(parent).is_ok_and(|resolved| resolved == parent) + && fs::symlink_metadata(output) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound), + "ordinary output must use an absent root below its canonical parent", + )?; + let held_parent = directory(parent)?; + fs::DirBuilder::new() + .mode(0o700) + .create(output) + .map_err(|_| invalid())?; + let held_output = directory(output)?; + same_directory(&held_parent, parent)?; + let destination = output.join(name); + let mut temporary = tempfile::Builder::new() + .prefix(".ordinary-") + .tempfile_in(output) + .map_err(|_| invalid())?; + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| invalid())?; + temporary.write_all(&bytes).map_err(|_| invalid())?; + temporary.as_file().sync_all().map_err(|_| invalid())?; + same_directory(&held_parent, parent)?; + same_directory(&held_output, output)?; + let persisted = temporary + .persist_noclobber(&destination) + .map_err(|_| invalid())?; + let verification = (|| { + checkpoint(WriteCheckpoint::Persisted, output)?; + held_output.sync_all().map_err(|_| invalid())?; + held_parent.sync_all().map_err(|_| invalid())?; + same_directory(&held_parent, parent)?; + same_directory(&held_output, output)?; + let retained = documents::read_bounded(&destination, role)?; + let entries = fs::read_dir(output) + .map_err(|_| invalid())? + .take(2) + .map(|entry| entry.map(|entry| entry.file_name())); + require( + retained.bytes == bytes && exact_entry(entries, name)?, + "ordinary output has changed or contains unexpected entries", + )?; + checkpoint(WriteCheckpoint::Verified, output) + })(); + if let Err(mut failure) = verification { + if let Err(cleanup) = remove_owned_result(&held_output, &persisted, name) { + failure = failure.attach(format!("ordinary output rollback failed: {cleanup:?}")); + } + return Err(failure); + } + Ok(destination) +} + +/// Attempts to remove a result still matching our persisted inode. +/// +/// Refuses observed replacements; the ordinary boundary does not contain a +/// hostile process with the same UID racing the final check and unlink. +fn remove_owned_result( + directory: &fs::File, + persisted: &fs::File, + name: &str, +) -> Result<(), Report> { + let own = persisted.metadata().map_err(|_| invalid())?; + if own.nlink() == 0 { + return Ok(()); + } + let name = CString::new(name).map_err(|_| invalid())?; + // SAFETY: the directory descriptor is live and the name is NUL-terminated. + // O_NOFOLLOW prevents opening a replacement symlink's target. + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + require( + descriptor >= 0, + "ordinary rollback cannot locate its owned result", + )?; + // SAFETY: openat returned a new owned descriptor, not held by another File. + let retained = unsafe { fs::File::from_raw_fd(descriptor) }; + let current = retained.metadata().map_err(|_| invalid())?; + require( + current.is_file() && (current.dev(), current.ino()) == (own.dev(), own.ino()), + "ordinary rollback refuses a replacement result", + )?; + // SAFETY: the held directory and NUL-terminated leaf remain live; no parent + // path is traversed, even if the output root was renamed or replaced. + let removed = unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) }; + require(removed == 0, "ordinary owned result cannot be removed")?; + directory.sync_all().map_err(|_| invalid())?; + require( + persisted.metadata().map_err(|_| invalid())?.nlink() == 0, + "ordinary result still has an unknown filesystem link", + ) +} + +fn exact_entry( + entries: impl Iterator>, + expected: &str, +) -> Result> { + let entries = entries + .collect::, _>>() + .map_err(|_| invalid())?; + Ok(entries.len() == 1 && entries[0] == expected) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/ordinary/output/tests.rs b/crates/trusted-server-release/src/ordinary/output/tests.rs new file mode 100644 index 0000000000..43b375c9ac --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/output/tests.rs @@ -0,0 +1,337 @@ +use std::fs; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; + +use serde_json::Value; + +use super::{COMPANION, companion, observation}; +use crate::ordinary::transfer::tests::checked_fixture; + +#[test] +fn failed_post_persist_or_final_verification_leaves_no_matched_companion() { + for point in [ + super::WriteCheckpoint::Persisted, + super::WriteCheckpoint::Verified, + ] { + let temporary = tempfile::tempdir().expect("should create failure fixture"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + let directory = parent.join("comparison"); + let result = super::persist_with_checkpoints( + &directory, + COMPANION, + &checked_fixture().receipt(), + crate::documents::Role::Companion, + &|site, _| { + if site == point { + Err(super::invalid()) + } else { + Ok(()) + } + }, + ); + assert!( + result.is_err(), + "should return the injected sealing failure" + ); + assert!( + !directory.join(COMPANION).exists(), + "should remove the owned matched result after every later error" + ); + } +} + +#[test] +fn real_membership_failure_removes_only_the_new_result() { + let temporary = tempfile::tempdir().expect("should create membership fixture"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + let directory = parent.join("comparison"); + let result = super::persist_with_checkpoints( + &directory, + COMPANION, + &checked_fixture().receipt(), + crate::documents::Role::Companion, + &|site, root| { + if site == super::WriteCheckpoint::Persisted { + fs::write(root.join("extra"), b"retained evidence") + .expect("should inject an extra output"); + } + Ok(()) + }, + ); + assert!( + result.is_err(), + "should fail the actual exact-membership check" + ); + assert!( + !directory.join(COMPANION).exists(), + "should remove its newly persisted result" + ); + assert_eq!( + fs::read(directory.join("extra")).expect("should retain other evidence"), + b"retained evidence", + "should not delete unrelated files" + ); +} + +#[test] +fn rollback_uses_the_original_directory_without_following_its_replacement() { + let temporary = tempfile::tempdir().expect("should create root replacement fixture"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + let directory = parent.join("comparison"); + let retained = parent.join("retained"); + let replacement = parent.join("replacement"); + fs::create_dir(&replacement).expect("should create unrelated replacement tree"); + fs::write(replacement.join(COMPANION), b"unrelated bytes") + .expect("should retain unrelated file"); + let result = super::persist_with_checkpoints( + &directory, + COMPANION, + &checked_fixture().receipt(), + crate::documents::Role::Companion, + &|site, root| { + if site == super::WriteCheckpoint::Persisted { + fs::rename(root, &retained).expect("should retain the original output directory"); + symlink(&replacement, root).expect("should replace output root with a symlink"); + return Err(super::invalid()); + } + Ok(()) + }, + ); + assert!( + result.is_err(), + "should fail sealing after the injected root change" + ); + assert!( + !retained.join(COMPANION).exists(), + "should unlink only the result inside its held directory" + ); + assert_eq!( + fs::read(replacement.join(COMPANION)).expect("should retain unrelated file"), + b"unrelated bytes", + "should never traverse the replacement root" + ); +} + +#[test] +fn rollback_preserves_a_replacement_leaf_when_its_own_result_is_already_unlinked() { + let temporary = tempfile::tempdir().expect("should create leaf replacement fixture"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + let directory = parent.join("comparison"); + let result = super::persist_with_checkpoints( + &directory, + COMPANION, + &checked_fixture().receipt(), + crate::documents::Role::Companion, + &|site, root| { + if site == super::WriteCheckpoint::Persisted { + fs::remove_file(root.join(COMPANION)).expect("should unlink the owned result"); + fs::write(root.join(COMPANION), b"replacement").expect("should replace the leaf"); + return Err(super::invalid()); + } + Ok(()) + }, + ); + assert!(result.is_err(), "should retain the failed command outcome"); + assert_eq!( + fs::read(directory.join(COMPANION)).expect("should retain replacement"), + b"replacement", + "should not remove a file it did not create" + ); +} + +#[test] +fn rollback_reports_a_retained_inode_that_was_moved_away_from_its_destination() { + let temporary = tempfile::tempdir().expect("should create moved-result fixture"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + let directory = parent.join("comparison"); + let moved = parent.join("moved-result"); + let result = super::persist_with_checkpoints( + &directory, + COMPANION, + &checked_fixture().receipt(), + crate::documents::Role::Companion, + &|site, root| { + if site == super::WriteCheckpoint::Persisted { + fs::rename(root.join(COMPANION), &moved).expect("should move the owned inode"); + fs::write(root.join(COMPANION), b"unrelated replacement") + .expect("should replace the leaf"); + return Err(super::invalid()); + } + Ok(()) + }, + ); + let error = result.expect_err("should fail rather than removing an observed replacement"); + assert!( + format!("{error:?}").contains("ordinary output rollback failed"), + "should report incomplete cleanup loudly" + ); + assert_eq!( + fs::read(directory.join(COMPANION)).expect("should preserve replacement"), + b"unrelated replacement", + "should not unlink another inode" + ); + assert!( + moved.exists(), + "should not guess an unknown location for the original inode" + ); +} + +#[test] +fn rollback_reports_an_unknown_hardlink_after_removing_the_owned_destination() { + let temporary = tempfile::tempdir().expect("should create linked-result fixture"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + let directory = parent.join("comparison"); + let linked = parent.join("linked-result"); + let result = super::persist_with_checkpoints( + &directory, + COMPANION, + &checked_fixture().receipt(), + crate::documents::Role::Companion, + &|site, root| { + if site == super::WriteCheckpoint::Persisted { + fs::hard_link(root.join(COMPANION), &linked) + .expect("should inject an extra inode link"); + } + Ok(()) + }, + ); + let error = result.expect_err("should reject the actual linked-file readback"); + assert!( + !directory.join(COMPANION).exists(), + "should remove the destination it created" + ); + assert!( + linked.exists(), + "should not guess or sweep other link locations" + ); + assert!( + format!("{error:?}").contains("ordinary output rollback failed"), + "should report observed incomplete cleanup" + ); +} + +#[test] +fn directory_iteration_errors_never_count_as_a_successful_entry() { + let entries = [Err(std::io::Error::other("fictional enumeration error"))]; + assert!( + super::exact_entry(entries.into_iter(), COMPANION).is_err(), + "should fail closed when directory enumeration fails" + ); + assert!( + !super::exact_entry([Ok("other.json".into())].into_iter(), COMPANION) + .expect("should inspect one successful entry"), + "should require the exact permanent filename" + ); +} + +#[test] +fn writes_one_real_matched_companion_atomically_in_a_private_fresh_directory() { + let temporary = tempfile::tempdir().expect("should create output parent"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + let directory = parent.join("comparison"); + let path = companion(&checked_fixture(), &directory) + .expect("should seal the real ordinary comparison"); + assert_eq!( + path, + directory.join(COMPANION), + "should retain the permanent index-contract filename" + ); + let value: Value = + serde_json::from_slice(&fs::read(&path).expect("should read sealed receipt")) + .expect("should parse real receipt"); + assert_eq!( + value["outcome"], "matched", + "should publish only the actual successful outcome" + ); + assert_eq!( + value["release_qualification"], false, + "should preserve ordinary scope" + ); + assert_eq!( + fs::read_dir(&directory) + .expect("should inspect exact outputs") + .count(), + 1, + "should emit no placeholder, index or extra companion" + ); + let metadata = fs::metadata(path).expect("should inspect receipt"); + assert_eq!( + metadata.nlink(), + 1, + "should retain a single-link regular output" + ); + assert_eq!( + metadata.permissions().mode() & 0o777, + 0o600, + "should write private file permissions" + ); + assert_eq!( + fs::metadata(directory) + .expect("should inspect output root") + .permissions() + .mode() + & 0o777, + 0o700, + "should create a private output root" + ); +} + +#[test] +fn refuses_reused_output_roots_without_overwriting_any_existing_bytes() { + let temporary = tempfile::tempdir().expect("should create output parent"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + let directory = parent.join("comparison"); + companion(&checked_fixture(), &directory).expect("should create the first real comparison"); + let original = fs::read(directory.join(COMPANION)).expect("should retain first output"); + assert!( + companion(&checked_fixture(), &directory).is_err(), + "should refuse reusing a comparison directory" + ); + assert_eq!( + fs::read(directory.join(COMPANION)).expect("should retain original receipt"), + original, + "should never overwrite an earlier result" + ); +} + +#[test] +fn refuses_aliased_output_parents_before_creating_a_result() { + let temporary = tempfile::tempdir().expect("should create output parent"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + fs::create_dir(parent.join("real")).expect("should create real parent"); + symlink("real", parent.join("alias")).expect("should alias output parent"); + assert!( + companion(&checked_fixture(), &parent.join("alias/comparison")).is_err(), + "should reject an output parent alias" + ); + assert!( + !parent.join("real/comparison").exists(), + "should produce no result after alias rejection" + ); +} + +#[test] +fn observation_transfer_is_separate_from_a_publishable_companion() { + let temporary = tempfile::tempdir().expect("should create output parent"); + let parent = fs::canonicalize(temporary.path()).expect("should resolve output parent"); + let observed = crate::ordinary::observation( + &serde_json::to_vec(&crate::ordinary::tests::fixture()) + .expect("should encode an ordinary observation fixture"), + ) + .expect("should parse ordinary data without granting qualification"); + let root = parent.join("observation"); + let path = + observation(&observed, &root).expect("should seal only the unpublished transfer sidecar"); + assert_eq!( + path, + root.join("fastly-observation.json"), + "should match the fixed observation archive profile" + ); + assert!( + !root.join(COMPANION).exists(), + "should not manufacture an equality result from an observation" + ); + assert!( + observation(&observed, &root).is_err(), + "should refuse reusing an observation transfer root too" + ); +} diff --git a/crates/trusted-server-release/src/ordinary/run.rs b/crates/trusted-server-release/src/ordinary/run.rs new file mode 100644 index 0000000000..000dc27a56 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/run.rs @@ -0,0 +1,342 @@ +//! Pure ordinary upload-output/API joins; transport authentication is external. + +use std::collections::BTreeSet; + +use error_stack::Report; +use serde::Deserialize; +use serde_json::Value; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use super::{ASSET, digest, require}; +use crate::documents::{self, Role}; +use crate::error::ReleaseError; + +const MAX_ID: u64 = 9_007_199_254_740_991; +const ARCHIVE_LIMIT: u64 = 768 * 1024 * 1024; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Upload { + artifact_id: u64, + artifact_digest: String, + observation_id: u64, + observation_digest: String, +} + +/// Invocation context supplied by the workflow, never a build observation. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Context { + pub(super) repository: String, + pub(super) repository_id: u64, + pub(super) head_repository_id: u64, + pub(super) source_commit: String, + pub(super) run_id: u64, + pub(super) run_attempt: u64, + canonical: Upload, + reproduction: Upload, +} + +/// Service-correlated archive identity; its content hash still needs checking. +#[derive(Debug)] +pub(super) struct Archive { + pub(super) id: u64, + pub(super) name: String, + pub(super) sha256: String, + pub(super) size: u64, +} + +/// One successful producing job listed for the current attempt, and its exact +/// two uploads. +/// +/// GitHub lists a job carried over from an earlier attempt under the rerun +/// attempt, with that attempt's `run_attempt` and its original uploads. Binding +/// such a job is intended: the bytes are still this run's real pair, and a +/// comparison-only rerun needs no rebuild. The `run_attempt` join only rejects +/// a job listing fetched for a different attempt. +#[derive(Debug)] +pub(super) struct Build { + pub(super) job_id: u64, + pub(super) started: OffsetDateTime, + pub(super) completed: OffsetDateTime, + pub(super) artifact: Archive, + pub(super) observation: Archive, +} + +/// Validated current-run ordinary bindings, without collection qualification. +#[derive(Debug)] +pub(super) struct Bindings { + context: Context, + canonical: Build, + reproduction: Build, +} + +impl Bindings { + /// The distinct canonical and reproduction producing jobs. + #[cfg(test)] + pub(super) fn job_ids(&self) -> [u64; 2] { + [self.canonical.job_id, self.reproduction.job_id] + } + + /// The immutable workflow context used by every joined record. + pub(super) fn context(&self) -> &Context { + &self.context + } + + /// The canonical and reproduction job bindings, in that order. + pub(super) fn builds(&self) -> [&Build; 2] { + [&self.canonical, &self.reproduction] + } +} + +fn json(bytes: &[u8]) -> Result> { + documents::parse_json(bytes, Role::Metadata).map_err(|_| { + Report::new(ReleaseError::Document).attach("ordinary service response JSON rejected") + }) +} + +fn positive(id: u64) -> bool { + (1..=MAX_ID).contains(&id) +} + +fn repository(repository: &str) -> bool { + repository.len() <= 200 + && repository.split('/').count() == 2 + && repository.split('/').all(|part| { + part.as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + }) +} + +fn page<'a>(value: &'a Value, key: &str) -> Result<&'a [Value], Report> { + let entries = value[key].as_array().ok_or_else(|| { + Report::new(ReleaseError::Document).attach("ordinary service response has no record list") + })?; + require( + entries.len() <= 100 && value["total_count"].as_u64() == Some(entries.len() as u64), + "ordinary service response is truncated or exceeds the single-page bound", + )?; + let mut ids = BTreeSet::new(); + for entry in entries { + require( + entry["id"] + .as_u64() + .is_some_and(|id| positive(id) && ids.insert(id)), + "ordinary service record ID is missing, invalid or duplicated", + )?; + } + Ok(entries) +} + +fn timestamp(value: &Value) -> Result> { + let text = value + .as_str() + .filter(|text| text.len() <= 40) + .ok_or_else(|| { + Report::new(ReleaseError::Document).attach("ordinary service timestamp is missing") + })?; + OffsetDateTime::parse(text, &Rfc3339).map_err(|_| { + Report::new(ReleaseError::Document).attach("ordinary service timestamp is invalid") + }) +} + +fn uploaded( + context: &Context, + artifacts: &[Value], + id: u64, + sha256: &str, + name: String, + interval: (OffsetDateTime, OffsetDateTime), +) -> Result> { + let matching: Vec<_> = artifacts + .iter() + .filter(|artifact| artifact["id"].as_u64() == Some(id)) + .collect(); + require( + matching.len() == 1, + "ordinary upload ID is absent or duplicated", + )?; + let artifact = matching[0]; + require( + artifacts + .iter() + .filter(|artifact| artifact["name"].as_str() == Some(&name)) + .count() + == 1, + "ordinary upload name is absent or duplicated", + )?; + let size = artifact["size_in_bytes"].as_u64().unwrap_or(0); + let run = &artifact["workflow_run"]; + require( + artifact["name"].as_str() == Some(&name) + && artifact["digest"].as_str() == Some(format!("sha256:{sha256}").as_str()) + && artifact["expired"].as_bool() == Some(false) + && (1..=ARCHIVE_LIMIT).contains(&size) + && run["id"].as_u64() == Some(context.run_id) + && run["repository_id"].as_u64() == Some(context.repository_id) + && run["head_repository_id"].as_u64() == Some(context.head_repository_id) + && run["head_sha"].as_str() == Some(&context.source_commit), + "ordinary upload service identity does not match its direct output", + )?; + let created = timestamp(&artifact["created_at"])?; + require( + interval.0 <= created && created <= interval.1, + "ordinary upload is outside its producing job interval", + )?; + Ok(Archive { + id, + name, + sha256: sha256.to_owned(), + size, + }) +} + +fn build( + context: &Context, + jobs: &[Value], + artifacts: &[Value], + upload: &Upload, + role: &str, +) -> Result> { + let name = match role { + "canonical" => format!("build / Build {ASSET}"), + _ => format!("build / Reproduce {ASSET}"), + }; + let matching: Vec<_> = jobs + .iter() + .filter(|job| job["name"].as_str() == Some(&name)) + .collect(); + require( + matching.len() == 1, + "ordinary producing job is absent or duplicated", + )?; + let job = matching[0]; + let job_id = job["id"].as_u64().unwrap_or(0); + require( + positive(job_id) + && job["run_id"].as_u64() == Some(context.run_id) + && job["run_attempt"].as_u64() == Some(context.run_attempt) + && job["head_sha"].as_str() == Some(&context.source_commit) + && job["status"].as_str() == Some("completed") + && job["conclusion"].as_str() == Some("success"), + "ordinary producing job is unsuccessful, stale or from another source", + )?; + let steps = job["steps"] + .as_array() + .filter(|steps| steps.len() <= 100) + .ok_or_else(|| { + Report::new(ReleaseError::Document).attach("ordinary upload steps are missing") + })?; + for name in [ + format!("Upload {role} release artifact"), + format!("Upload {role} observation"), + ] { + let steps: Vec<_> = steps + .iter() + .filter(|step| step["name"].as_str() == Some(&name)) + .collect(); + require( + steps.len() == 1 + && steps[0]["status"].as_str() == Some("completed") + && steps[0]["conclusion"].as_str() == Some("success"), + "ordinary upload step did not succeed exactly once", + )?; + } + let started = timestamp(&job["started_at"])?; + let completed = timestamp(&job["completed_at"])?; + require( + started.unix_timestamp() > 0 && started <= completed, + "ordinary job interval rejected", + )?; + let artifact_name = if role == "canonical" { + format!("release-{ASSET}") + } else { + format!("reproduction-{ASSET}") + }; + Ok(Build { + job_id, + started, + completed, + artifact: uploaded( + context, + artifacts, + upload.artifact_id, + &upload.artifact_digest, + artifact_name, + (started, completed), + )?, + observation: uploaded( + context, + artifacts, + upload.observation_id, + &upload.observation_digest, + format!("fastly-observation-{role}"), + (started, completed), + )?, + }) +} + +/// Joins trusted invocation/upload outputs with authenticated API responses. +pub(super) fn bind( + request: &[u8], + jobs: &[u8], + artifacts: &[u8], +) -> Result> { + let context: Context = serde_json::from_value(json(request)?).map_err(|_| { + Report::new(ReleaseError::Document).attach("ordinary workflow context fields rejected") + })?; + require( + repository(&context.repository) + && [ + context.repository_id, + context.head_repository_id, + context.run_id, + context.run_attempt, + ] + .into_iter() + .all(positive) + && digest(&context.source_commit, 40), + "ordinary workflow context identity rejected", + )?; + let mut ids = BTreeSet::new(); + for upload in [&context.canonical, &context.reproduction] { + require( + positive(upload.artifact_id) + && positive(upload.observation_id) + && ids.insert(upload.artifact_id) + && ids.insert(upload.observation_id) + && digest(&upload.artifact_digest, 64) + && digest(&upload.observation_digest, 64), + "ordinary direct upload outputs are invalid or reused", + )?; + } + let jobs = json(jobs)?; + let artifacts = json(artifacts)?; + let jobs = page(&jobs, "jobs")?; + let artifacts = page(&artifacts, "artifacts")?; + let canonical = build(&context, jobs, artifacts, &context.canonical, "canonical")?; + let reproduction = build( + &context, + jobs, + artifacts, + &context.reproduction, + "reproduction", + )?; + require( + canonical.job_id != reproduction.job_id, + "ordinary producing job ID reused", + )?; + Ok(Bindings { + context, + canonical, + reproduction, + }) +} + +#[cfg(test)] +pub(super) mod tests; diff --git a/crates/trusted-server-release/src/ordinary/run/tests.rs b/crates/trusted-server-release/src/ordinary/run/tests.rs new file mode 100644 index 0000000000..7b79d0db00 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/run/tests.rs @@ -0,0 +1,331 @@ +use serde_json::{Value, json}; + +use super::bind; + +fn encoded(value: &Value) -> Vec { + serde_json::to_vec(value).expect("should encode fixture") +} + +pub(in crate::ordinary) fn request() -> Value { + json!({ + "repository": "example-owner/example-server", + "repository_id": 17, + "head_repository_id": 17, + "source_commit": "a".repeat(40), + "run_id": 42, + "run_attempt": 1, + "canonical": { + "artifact_id": 200, + "artifact_digest": "b".repeat(64), + "observation_id": 201, + "observation_digest": "c".repeat(64) + }, + "reproduction": { + "artifact_id": 202, + "artifact_digest": "d".repeat(64), + "observation_id": 203, + "observation_digest": "e".repeat(64) + } + }) +} + +pub(in crate::ordinary) fn jobs() -> Value { + let jobs: Vec<_> = [ + (100, "Build trusted-server-fastly.wasm", "canonical", "2023-11-14T22:13:10Z", "2023-11-14T22:13:30Z"), + (101, "Reproduce trusted-server-fastly.wasm", "reproduction", "2023-11-14T22:28:10Z", "2023-11-14T22:28:30Z"), + ] + .into_iter() + .map(|(id, name, role, start, end)| { + json!({ + "id": id, "name": format!("build / {name}"), "run_id": 42, + "run_attempt": 1, "head_sha": "a".repeat(40), + "status": "completed", "conclusion": "success", + "started_at": start, "completed_at": end, + "steps": [ + {"name": format!("Upload {role} release artifact"), "status": "completed", "conclusion": "success"}, + {"name": format!("Upload {role} observation"), "status": "completed", "conclusion": "success"} + ] + }) + }).collect(); + json!({"total_count": jobs.len(), "jobs": jobs}) +} + +pub(in crate::ordinary) fn artifacts() -> Value { + let artifacts: Vec<_> = [ + (200, "release-trusted-server-fastly.wasm", "b", "2023-11-14T22:13:25Z"), + (201, "fastly-observation-canonical", "c", "2023-11-14T22:13:26Z"), + (202, "reproduction-trusted-server-fastly.wasm", "d", "2023-11-14T22:28:25Z"), + (203, "fastly-observation-reproduction", "e", "2023-11-14T22:28:26Z"), + ].into_iter().map(|(id, name, hex, created)| json!({ + "id": id, "name": name, "digest": format!("sha256:{}", hex.repeat(64)), + "size_in_bytes": 42, "expired": false, "created_at": created, + "workflow_run": {"id": 42, "repository_id": 17, "head_repository_id": 17, "head_sha": "a".repeat(40)} + })).collect(); + json!({"total_count": artifacts.len(), "artifacts": artifacts}) +} + +#[test] +fn binds_direct_upload_outputs_to_successful_current_attempt_jobs() { + let bound = bind( + &encoded(&request()), + &encoded(&jobs()), + &encoded(&artifacts()), + ) + .expect("should bind exact current-run upload identities"); + assert_eq!( + bound.job_ids(), + [100, 101], + "should retain distinct producing jobs" + ); +} + +#[test] +fn rejects_duplicate_service_job_ids_even_under_an_unrelated_name() { + let mut response = jobs(); + let mut duplicate = response["jobs"][0].clone(); + duplicate["name"] = json!("Unrelated job"); + response["jobs"] + .as_array_mut() + .expect("should hold jobs") + .push(duplicate); + response["total_count"] = json!(3); + assert!( + bind( + &encoded(&request()), + &encoded(&response), + &encoded(&artifacts()) + ) + .is_err(), + "should reject globally inconsistent service job identities" + ); +} + +#[test] +fn rejects_uploads_before_job_start_even_within_the_same_second() { + let mut jobs = jobs(); + let mut artifacts = artifacts(); + jobs["jobs"][0]["started_at"] = json!("2023-11-14T22:13:10.900Z"); + artifacts["artifacts"][0]["created_at"] = json!("2023-11-14T22:13:10.100Z"); + assert!( + bind(&encoded(&request()), &encoded(&jobs), &encoded(&artifacts)).is_err(), + "should retain timestamp precision when binding an upload to its job" + ); +} + +#[test] +fn rejects_foreign_source_runs_attempts_and_unsuccessful_jobs() { + // The attempt-scoped jobs endpoint lists carried-over jobs under the current + // attempt; a different `run_attempt` means the listing was fetched for + // another attempt. + for (pointer, value) in [ + ("/jobs/0/run_id", json!(43)), + ("/jobs/0/run_attempt", json!(2)), + ("/jobs/0/head_sha", json!("f".repeat(40))), + ("/jobs/0/status", json!("in_progress")), + ("/jobs/0/conclusion", json!("failure")), + ("/jobs/0/run_attempt", Value::Null), + ] { + let mut response = jobs(); + *response + .pointer_mut(pointer) + .expect("should locate fixture field") = value; + let error = bind( + &encoded(&request()), + &encoded(&response), + &encoded(&artifacts()), + ) + .expect_err("should reject a stale, foreign or unsuccessful producing job"); + assert!( + format!("{error:?}") + .contains("producing job is unsuccessful, stale or from another source"), + "should reach the source/run/attempt join for {pointer}" + ); + } +} + +#[test] +fn rejects_a_job_that_completes_before_it_starts() { + let mut response = jobs(); + response["jobs"][0]["completed_at"] = json!("2023-11-14T22:13:09Z"); + let error = bind( + &encoded(&request()), + &encoded(&response), + &encoded(&artifacts()), + ) + .expect_err("should reject an inverted job interval"); + assert!( + format!("{error:?}").contains("ordinary job interval rejected"), + "should reach the job interval rule" + ); +} + +#[test] +fn rejects_a_second_upload_with_the_same_artifact_name() { + let mut response = artifacts(); + let mut stale = response["artifacts"][0].clone(); + stale["id"] = json!(299); + response["artifacts"] + .as_array_mut() + .expect("should hold artifacts") + .push(stale); + response["total_count"] = json!(5); + let error = bind(&encoded(&request()), &encoded(&jobs()), &encoded(&response)) + .expect_err("should reject an ambiguous artifact name"); + assert!( + format!("{error:?}").contains("upload name is absent or duplicated"), + "should refuse to bind when an earlier upload shares the artifact name" + ); +} + +#[test] +fn rejects_reused_upload_outputs_and_malformed_workflow_context() { + for (pointer, value) in [ + ("/reproduction/artifact_id", json!(200)), + ("/canonical/artifact_id", json!(0)), + ("/canonical/artifact_digest", json!("B".repeat(64))), + ("/repository", json!("../example-server")), + ("/source_commit", json!("a".repeat(39))), + ("/run_attempt", json!(0)), + ("/repository_id", json!(9_007_199_254_740_992_u64)), + ] { + let mut response = request(); + *response + .pointer_mut(pointer) + .expect("should locate fixture field") = value; + assert!( + bind( + &encoded(&response), + &encoded(&jobs()), + &encoded(&artifacts()) + ) + .is_err(), + "should reject an invalid trusted-context/output shape at {pointer}" + ); + } +} + +#[test] +fn rejects_upload_metadata_disagreeing_with_the_direct_output_bridge() { + for (pointer, value) in [ + ("/artifacts/0/name", json!("other-build")), + ( + "/artifacts/0/digest", + json!(format!("sha256:{}", "f".repeat(64))), + ), + ("/artifacts/0/expired", json!(true)), + ("/artifacts/0/size_in_bytes", json!(0)), + ( + "/artifacts/0/size_in_bytes", + json!(768 * 1024 * 1024 + 1_u64), + ), + ("/artifacts/0/workflow_run/id", json!(43)), + ("/artifacts/0/workflow_run/repository_id", json!(18)), + ("/artifacts/0/workflow_run/head_repository_id", json!(18)), + ("/artifacts/0/workflow_run/head_sha", json!("f".repeat(40))), + ] { + let mut response = artifacts(); + *response + .pointer_mut(pointer) + .expect("should locate fixture field") = value; + assert!( + bind(&encoded(&request()), &encoded(&jobs()), &encoded(&response)).is_err(), + "should reject service metadata mismatching the direct output at {pointer}" + ); + } +} + +#[test] +fn rejects_uploads_outside_their_successful_producing_job() { + for value in [ + "2023-11-14T22:13:09Z", + "2023-11-14T22:13:31Z", + "not-a-service-timestamp", + ] { + let mut response = artifacts(); + response["artifacts"][0]["created_at"] = json!(value); + assert!( + bind(&encoded(&request()), &encoded(&jobs()), &encoded(&response)).is_err(), + "should reject stale, future or invalid upload creation time" + ); + } +} + +#[test] +fn rejects_missing_failed_and_duplicate_upload_steps() { + for value in [json!("failure"), json!("skipped"), Value::Null] { + let mut response = jobs(); + response["jobs"][0]["steps"][0]["conclusion"] = value; + assert!( + bind( + &encoded(&request()), + &encoded(&response), + &encoded(&artifacts()) + ) + .is_err(), + "should require the exact successful upload step" + ); + } + let mut response = jobs(); + let duplicate = response["jobs"][0]["steps"][0].clone(); + response["jobs"][0]["steps"] + .as_array_mut() + .expect("should list steps") + .push(duplicate); + assert!( + bind( + &encoded(&request()), + &encoded(&response), + &encoded(&artifacts()) + ) + .is_err(), + "should reject ambiguous duplicate upload steps" + ); +} + +#[test] +fn rejects_truncated_pages_duplicate_ids_and_missing_expected_jobs() { + let mut response = jobs(); + response["total_count"] = json!(3); + assert!( + bind( + &encoded(&request()), + &encoded(&response), + &encoded(&artifacts()) + ) + .is_err(), + "should reject a truncated jobs page" + ); + let mut response = artifacts(); + response["artifacts"][3]["id"] = json!(200); + assert!( + bind(&encoded(&request()), &encoded(&jobs()), &encoded(&response)).is_err(), + "should reject globally duplicated service artifact IDs" + ); + let mut response = jobs(); + response["jobs"][1]["name"] = json!("Other unrelated job"); + assert!( + bind( + &encoded(&request()), + &encoded(&response), + &encoded(&artifacts()) + ) + .is_err(), + "should never infer another job as reproduction from its uploaded filename" + ); +} + +#[test] +fn rejects_duplicate_json_keys_before_service_or_context_interpretation() { + let duplicate = String::from_utf8(encoded(&request())) + .expect("should encode UTF-8") + .replace("\"run_attempt\":1", "\"run_attempt\":1,\"run_attempt\":1"); + assert!( + bind( + duplicate.as_bytes(), + &encoded(&jobs()), + &encoded(&artifacts()) + ) + .is_err(), + "should reject duplicate context keys before selecting identities" + ); +} diff --git a/crates/trusted-server-release/src/ordinary/tests.rs b/crates/trusted-server-release/src/ordinary/tests.rs new file mode 100644 index 0000000000..40997b4b0b --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/tests.rs @@ -0,0 +1,467 @@ +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use std::path::Path; + +use super::{Observation, compare, observation, pair}; + +const WASM: &[u8] = b"\0asm\x01\0\0\0"; + +pub(super) fn fixture() -> Value { + json!({ + "version": 1, + "profile": "ordinary-release-fastly-byte-equality-v1", + "release_qualification": false, + "source_commit": "a".repeat(40), + "asset": "trusted-server-fastly.wasm", + "target": "wasm32-wasip1", + "variant": "default", + "role": "canonical", + "recipe_sha256": "b".repeat(64), + "cargo_lock_sha256": "c".repeat(64), + "npm_lock_sha256": "d".repeat(64), + "js_inventory_sha256": "e".repeat(64), + "rustflags_sha256": super::normalization::plan(Path::new("/example/workspace/ordinary-canonical-source"), Path::new("/example/temp/ordinary-fastly-canonical-target"), "canonical").expect("should derive actual argument identity").sha256, + "tools": { + "rustc": {"version": "rustc 1.98.1 (example)", "sha256": "f".repeat(64)}, + "node": {"version": "v24.20.0", "sha256": "1".repeat(64)}, + "acquisition": "ordinary-hosted-toolchain" + }, + "environment": { + "checkout_root": "/example/workspace/ordinary-canonical-source", + "target_root": "/example/temp/ordinary-fastly-canonical-target", + "cargo_home": "/example/temp/ordinary-canonical-cargo-home", + "started_at_seconds": 1700000000, + "umask": 18, + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "uid": 1001 + }, + "package": {"size": 7, "sha256": hex::encode(Sha256::digest(b"package"))}, + "artifact": {"size": WASM.len(), "sha256": hex::encode(Sha256::digest(WASM))}, + "gaps": [ + "alternate-user-unqualified", + "boundary-unqualified", + "materials-unqualified", + "tools-unqualified" + ] + }) +} + +fn parse(value: &Value) -> Observation { + observation(&serde_json::to_vec(value).expect("should encode fixture")) + .expect("should validate the observation fixture") +} + +pub(super) fn reproduction() -> Value { + let mut value = fixture(); + value["role"] = json!("reproduction"); + value["environment"]["checkout_root"] = + json!("/example/workspace/ordinary-reproduction-source"); + value["environment"]["target_root"] = + json!("/example/temp/ordinary-fastly-reproduction-target"); + value["environment"]["cargo_home"] = json!("/example/temp/ordinary-reproduction-cargo-home"); + value["environment"]["started_at_seconds"] = json!(1700000900); + value["environment"]["umask"] = json!(23); + value["environment"]["jobs"] = json!(2); + value +} + +#[test] +fn accepts_matching_inputs_with_observed_environment_variation() { + pair(&parse(&fixture()), &parse(&reproduction())) + .expect("should accept matching ordinary inputs with measured variation"); +} + +#[test] +fn rejects_unbound_raw_arguments_or_different_derived_sibling_maps() { + let mut value = reproduction(); + value["rustflags_sha256"] = json!("9".repeat(64)); + assert!( + pair(&parse(&fixture()), &parse(&value)).is_err(), + "should reject arguments not bound to the stated physical roots" + ); + let mut value = reproduction(); + value["environment"]["checkout_root"] = json!("/other/workspace/ordinary-reproduction-source"); + let flags = super::normalization::plan( + Path::new( + value["environment"]["checkout_root"] + .as_str() + .expect("should name source"), + ), + Path::new( + value["environment"]["target_root"] + .as_str() + .expect("should name target"), + ), + "reproduction", + ) + .expect("should derive individually valid different maps"); + value["rustflags_sha256"] = json!(flags.sha256); + assert!( + pair(&parse(&fixture()), &parse(&value)).is_err(), + "should reject individually valid but unequal raw arguments" + ); +} + +#[test] +fn compares_complete_final_bytes_for_an_ordinary_pair() { + let matched = compare(parse(&fixture()), parse(&reproduction()), WASM, WASM) + .expect("should compare the complete identical ordinary final bytes"); + assert_eq!( + matched.sha256(), + hex::encode(Sha256::digest(WASM)), + "should retain the actual compared digest" + ); + assert_eq!( + matched.size(), + 8, + "should retain the complete compared size" + ); +} + +fn with_bytes(mut value: Value, bytes: &[u8]) -> Observation { + value["artifact"] = json!({ + "size": bytes.len(), + "sha256": hex::encode(Sha256::digest(bytes)), + }); + parse(&value) +} + +#[test] +fn rejects_custom_section_differences_with_bounded_exact_diagnostics() { + let left = [WASM, b"\0\x03\x01x\0"].concat(); + let right = [WASM, b"\0\x03\x01x\x01"].concat(); + let error = compare( + with_bytes(fixture(), &left), + with_bytes(reproduction(), &right), + &left, + &right, + ) + .err() + .expect("should reject changed custom-section bytes without exclusions"); + // The owned mismatch evidence is bounded independently of optional backtraces. + let diagnostic = error + .downcast_ref::() + .expect("should retain bounded mismatch evidence"); + assert!( + diagnostic.len() < 2048 + && diagnostic.contains("final-byte mismatch") + && diagnostic.contains("first_offset=12") + && diagnostic.contains(&hex::encode(Sha256::digest(&left))) + && diagnostic.contains(&hex::encode(Sha256::digest(&right))), + "should retain bounded exact-byte mismatch evidence" + ); +} + +#[test] +fn rejects_final_bytes_changed_since_the_observation() { + let changed = [WASM, &[0]].concat(); + let error = compare(parse(&fixture()), parse(&reproduction()), WASM, &changed) + .err() + .expect("should reject mutation after measurement"); + assert!( + format!("{error:?}").contains("executable changed after observation"), + "should fail the retained measurement binding before comparison" + ); +} + +#[test] +fn rejects_size_only_differences_without_a_prefix_match_fallback() { + let longer = [WASM, b"\0\x02\x01x"].concat(); + let error = compare( + with_bytes(fixture(), WASM), + with_bytes(reproduction(), &longer), + WASM, + &longer, + ) + .err() + .expect("should reject equal prefix with a longer final file"); + assert!( + format!("{error:?}").contains("first_offset=8"), + "should identify the exact end-of-file difference" + ); +} + +#[test] +fn rejects_equal_artifact_claims_over_different_build_inputs() { + for field in [ + "source_commit", + "recipe_sha256", + "cargo_lock_sha256", + "npm_lock_sha256", + "js_inventory_sha256", + ] { + let mut changed = reproduction(); + changed[field] = json!("3".repeat(if field == "source_commit" { 40 } else { 64 })); + let error = pair(&parse(&fixture()), &parse(&changed)) + .expect_err("should reject changed input identities even with equal artifact claims"); + assert!( + format!("{error:?}").contains("build inputs differ"), + "should reach the input identity join for {field}" + ); + } + for tool in ["rustc", "node"] { + let mut changed = reproduction(); + changed["tools"][tool]["sha256"] = json!("3".repeat(64)); + assert!( + pair(&parse(&fixture()), &parse(&changed)).is_err(), + "should reject a changed tool identity" + ); + } +} + +#[test] +fn rejects_false_environment_variation_and_swapped_roles() { + for (field, value) in [ + ("started_at_seconds", json!(1700000000)), + ("umask", json!(18)), + ("jobs", json!(4)), + ("uid", json!(2002)), + ] { + let mut changed = reproduction(); + changed["environment"][field] = value; + let error = pair(&parse(&fixture()), &parse(&changed)) + .expect_err("should reject false or unsupported variation"); + assert!( + format!("{error:?}").contains("environment pairing rejected"), + "should reach the pairing rule for {field}" + ); + } + for (field, value) in [("umask", json!(23)), ("jobs", json!(2))] { + let mut changed = fixture(); + changed["environment"][field] = value; + let error = pair(&parse(&changed), &parse(&reproduction())) + .expect_err("should reject canonical-side variation outside the fixed pairing"); + assert!( + format!("{error:?}").contains("environment pairing rejected"), + "should reach the pairing rule for canonical {field}" + ); + } + assert!( + pair(&parse(&reproduction()), &parse(&fixture())).is_err(), + "should reject reversed canonical/reproduction roles" + ); +} + +#[test] +fn rejects_overlapping_cross_build_roots() { + let mut changed = reproduction(); + changed["environment"]["checkout_root"] = + json!("/example/workspace/ordinary-canonical-source/child"); + let error = pair(&parse(&fixture()), &parse(&changed)) + .expect_err("should reject overlapping roots instead of treating spelling as independence"); + assert!( + format!("{error:?}").contains("build roots overlap"), + "should reach the cross-build root check" + ); +} + +#[test] +fn rejects_invalid_digest_bounds_and_missing_assurance_gaps() { + for (field, value) in [ + ("source_commit", json!("A".repeat(40))), + ("recipe_sha256", json!("b".repeat(63))), + ("gaps", json!([])), + ("profile", json!("controlled-recipe-qualified")), + ] { + let mut input = fixture(); + input[field] = value; + assert!( + observation(&serde_json::to_vec(&input).expect("should encode fixture")).is_err(), + "should reject invalid {field}" + ); + } + for size in [0, 512 * 1024 * 1024 + 1] { + let mut input = fixture(); + input["artifact"]["size"] = json!(size); + assert!( + observation(&serde_json::to_vec(&input).expect("should encode fixture")).is_err(), + "should reject invalid artifact size" + ); + } +} + +#[test] +fn accepts_observed_ordinary_inputs_without_qualification() { + let input = fixture(); + let accepted = observation(&serde_json::to_vec(&input).expect("should encode fixture")) + .expect("should accept the complete ordinary observation"); + assert_eq!( + accepted.value(), + &input, + "should retain observed facts exactly" + ); +} + +#[test] +fn rejects_forged_qualification_and_unknown_observation_fields() { + let mut qualified = fixture(); + qualified["release_qualification"] = json!(true); + assert!( + observation(&serde_json::to_vec(&qualified).expect("should encode fixture")).is_err(), + "should reject a qualification claim" + ); + let mut extra = fixture(); + extra["approved_source"] = json!(true); + assert!( + observation(&serde_json::to_vec(&extra).expect("should encode fixture")).is_err(), + "should reject an unapproved observation field" + ); +} + +#[test] +fn rejects_environment_root_spelling_aliases() { + for path in [ + "/example/canonical/source/", + "/example//canonical/source", + "/example/./canonical/source", + "/example/other/../canonical/source", + ] { + let mut input = fixture(); + input["environment"]["checkout_root"] = json!(path); + assert!( + observation(&serde_json::to_vec(&input).expect("should encode fixture")).is_err(), + "should reject a root spelling alias: {path}" + ); + } +} + +#[test] +fn invalid_observation_diagnostics_do_not_echo_arbitrary_field_names() { + let mut input = fixture(); + input["unexpected_observation_key".repeat(2048)] = json!(true); + let error = observation(&serde_json::to_vec(&input).expect("should encode fixture")) + .err() + .expect("should reject the unapproved field"); + // Caller-enabled backtraces are separate from the fixed sanitized payload. + let diagnostic = error + .downcast_ref::<&str>() + .expect("should retain the fixed diagnostic"); + assert_eq!( + *diagnostic, "ordinary observation fields or types rejected", + "should discard the original parser diagnostic" + ); + assert!( + diagnostic.len() < 2048 && !format!("{error:?}").contains("unexpected_observation_key"), + "should keep diagnostics bounded without echoing untrusted field names" + ); +} + +#[test] +fn typed_failure_stages_distinguish_identity_size_digest_and_complete_bytes() { + for (stage, code) in [ + ("identity", "identity_mismatch"), + ("size", "observed_size_mismatch"), + ("digest", "observed_digest_mismatch"), + ("bytes", "final_bytes_mismatch"), + ] { + let mut right = reproduction(); + let different = [WASM, b"\0\x02\x01x"].concat(); + let bytes = if stage == "bytes" { + different.as_slice() + } else { + WASM + }; + match stage { + "identity" => right["source_commit"] = json!("b".repeat(40)), + "size" => right["artifact"]["size"] = json!(WASM.len() + 1), + "digest" => right["artifact"]["sha256"] = json!("b".repeat(64)), + "bytes" => { + right["artifact"] = + json!({"size":bytes.len(),"sha256":hex::encode(Sha256::digest(bytes))}) + } + _ => unreachable!("should use a fixed test case"), + } + let error = compare(parse(&fixture()), parse(&right), WASM, bytes) + .err() + .expect("should reject the selected actual failure boundary"); + let value = error + .downcast_ref::() + .expect("should retain typed failure facts rather than parsing stderr") + .value(); + assert_eq!( + value["stage"], stage, + "should identify actual rejecting stage" + ); + assert_eq!( + value["code"], code, + "should retain fixed code for the stage" + ); + if stage == "bytes" { + assert_eq!( + value["outcome"], "mismatched", + "should recognize nondeterminism" + ); + assert_eq!( + value["facts"]["first_difference_offset"], + WASM.len(), + "should retain actual first difference" + ); + } + } +} + +#[test] +fn rejects_a_cargo_home_other_than_the_role_private_home() { + for home in [ + "/example/temp/ordinary-reproduction-cargo-home", + "/home/example/.cargo", + "/example/other/ordinary-canonical-cargo-home", + ] { + let mut value = fixture(); + value["environment"]["cargo_home"] = json!(home); + let Err(error) = observation(&serde_json::to_vec(&value).expect("should encode fixture")) + else { + panic!("should reject a shared or foreign Cargo home: {home}"); + }; + assert!( + format!("{error:?}").contains("Cargo home is not the role's private home"), + "should reach the private Cargo home rule for {home}" + ); + } +} + +#[test] +fn rejects_compare_time_identity_environment_and_acquisition_changes() { + for (pointer, value, reason) in [ + ( + "/target", + json!("wasm32-unknown-unknown"), + "identity or qualification claim rejected", + ), + ( + "/variant", + json!("example"), + "identity or qualification claim rejected", + ), + ( + "/environment/locale", + json!("en_US.UTF-8"), + "environment observations rejected", + ), + ( + "/environment/timezone", + json!("Europe/Example"), + "environment observations rejected", + ), + ( + "/tools/acquisition", + json!("example-acquisition"), + "tool observations rejected", + ), + ] { + let mut input = fixture(); + *input + .pointer_mut(pointer) + .expect("should locate fixture field") = value; + let Err(error) = observation(&serde_json::to_vec(&input).expect("should encode fixture")) + else { + panic!("should reject a changed {pointer}"); + }; + assert!( + format!("{error:?}").contains(reason), + "should reach the `{reason}` rule for {pointer}" + ); + } +} diff --git a/crates/trusted-server-release/src/ordinary/transfer.rs b/crates/trusted-server-release/src/ordinary/transfer.rs new file mode 100644 index 0000000000..40f178ad3a --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/transfer.rs @@ -0,0 +1,223 @@ +//! Ordinary archive-byte joins, without authenticated transport authority. + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use std::collections::BTreeMap; +use std::path::Path; + +use super::archive::{self, Profile}; +use super::run::{Archive, Bindings, Build}; +use super::{ + ASSET, Equality, Observation, PACKAGE, compare, diagnostic, observation, require, require_as, +}; +use crate::error::ReleaseError; + +/// Four exact-ID downloads in canonical build/observation, reproduction order. +pub(super) struct Archives<'a>(pub(super) [&'a [u8]; 4]); + +/// A complete ordinary equality receipt and retained canonical release inputs. +pub(super) struct Checked { + bindings: Bindings, + equality: Equality, + binary: Vec, + package: Vec, + spdx: Vec, + reproduction_spdx_sha256: String, +} + +impl Checked { + /// The informational ordinary receipt; it grants no qualification. + pub(super) fn receipt(&self) -> Value { + let context = self.bindings.context(); + let [canonical, reproduction] = self.bindings.builds(); + let observed = self.equality.canonical.value(); + json!({ + "version": 1, + "profile": observed["profile"], + "release_qualification": false, + "source_commit": context.source_commit, + "asset": ASSET, + "target": "wasm32-wasip1", + "variant": "default", + "recipe_sha256": observed["recipe_sha256"], + "cargo_lock_sha256": observed["cargo_lock_sha256"], + "npm_lock_sha256": observed["npm_lock_sha256"], + "js_inventory_sha256": observed["js_inventory_sha256"], + "tools": observed["tools"], + "outcome": "matched", + "package": {"asset": PACKAGE,"size":self.package.len(),"canonical_sha256":hex::encode(Sha256::digest(&self.package)),"reproduction_sha256":hex::encode(Sha256::digest(&self.package)),"manifest_sha256":hex::encode(Sha256::digest(crate::package::RELEASE_MANIFEST))}, + "canonical_sha256": self.equality.sha256(), + "reproduction_sha256": self.equality.sha256(), + "canonical": self.build_record(canonical, &self.equality.canonical, &hex::encode(Sha256::digest(&self.spdx))), + "reproduction": self.build_record(reproduction, &self.equality.reproduction, &self.reproduction_spdx_sha256), + "gaps": observed["gaps"], + }) + } + + fn build_record(&self, build: &Build, observation: &Observation, spdx: &str) -> Value { + let context = self.bindings.context(); + let upload = |archive: &Archive| { + json!({ + "id": archive.id, "name": archive.name, + "sha256": archive.sha256, "size": archive.size, + }) + }; + json!({ + "run_id": context.run_id, + "run_attempt": context.run_attempt, + "job_id": build.job_id, + "artifact": upload(&build.artifact), + "observation_artifact": upload(&build.observation), + "environment": observation.value()["environment"], + "size": self.equality.size(), + "sha256": observation.facts.artifact.sha256, + "spdx_sha256": spdx, + "package": {"size":observation.facts.package.size,"sha256":observation.facts.package.sha256}, + }) + } + + /// Exact canonical inputs retained from the checked build archive. + pub(super) fn inputs(&self) -> (&[u8], &[u8], &[u8]) { + (&self.binary, &self.spdx, &self.package) + } +} + +fn unpack( + bytes: &[u8], + expected: &Archive, + profile: Profile, +) -> Result>, Report> { + let limit = match profile { + Profile::Build => 768 * 1024 * 1024, + Profile::Observation => 2 * 1024 * 1024, + }; + require( + bytes.len() <= limit + && bytes.len() as u64 == expected.size + && hex::encode(Sha256::digest(bytes)) == expected.sha256, + "ordinary archive digest or size disagrees with its upload/service identity", + ) + .map_err(|report| diagnostic::tag(report, diagnostic::Code::TransferRejected))?; + archive::contents(bytes, profile) + .map_err(|report| diagnostic::tag(report, diagnostic::Code::TransferRejected)) +} + +fn observed( + bytes: &[u8], + bindings: &Bindings, + build: &Build, +) -> Result> { + let observed = observation(bytes) + .map_err(|report| diagnostic::tag(report, diagnostic::Code::InputsRejected))?; + let started = observed.facts.environment.started_at_seconds; + // Observations record whole seconds; the server upload interval above keeps + // its full precision. Compare the observation's stated resolution here. + require_as( + ReleaseError::RebuildIdentity, + observed.facts.source_commit == bindings.context().source_commit + && started >= build.started.unix_timestamp() as u64 + && started <= build.completed.unix_timestamp() as u64, + "ordinary observation source or start disagrees with its producing job", + ) + .map_err(|report| diagnostic::tag(report, diagnostic::Code::IdentityMismatch))?; + Ok(observed) +} + +/// Checks whole archives before parsing and joins retained bytes to observations. +pub(super) fn check( + bindings: Bindings, + archives: &Archives<'_>, +) -> Result> { + let [canonical, reproduction] = bindings.builds(); + let mut left = unpack(archives.0[0], &canonical.artifact, Profile::Build)?; + let left_observation = unpack(archives.0[1], &canonical.observation, Profile::Observation)?; + let right = unpack(archives.0[2], &reproduction.artifact, Profile::Build)?; + let right_observation = unpack( + archives.0[3], + &reproduction.observation, + Profile::Observation, + )?; + let canonical = observed( + &left_observation["fastly-observation.json"], + &bindings, + canonical, + )?; + let reproduction = observed( + &right_observation["fastly-observation.json"], + &bindings, + reproduction, + )?; + let equality = compare(canonical, reproduction, &left[ASSET], &right[ASSET])?; + for (observed, bytes) in [ + (&equality.canonical, &left[ASSET]), + (&equality.reproduction, &right[ASSET]), + ] { + let facts = &observed.facts; + let flags = super::normalization::plan( + Path::new(&facts.environment.checkout_root), + Path::new(&facts.environment.target_root), + &facts.role, + )?; + super::normalization::scan(bytes, &flags).map_err(|report| { + report.attach( + diagnostic::Failure::new(diagnostic::Code::InputsRejected) + .binaries(&left[ASSET], &right[ASSET]), + ) + })?; + } + for (observed, bytes) in [ + (&equality.canonical, &left[PACKAGE]), + (&equality.reproduction, &right[PACKAGE]), + ] { + require_as( + ReleaseError::RebuildMismatch, + observed.facts.package.size == bytes.len() as u64 + && observed.facts.package.sha256 == hex::encode(Sha256::digest(bytes)), + "ordinary deployment package changed after observation", + ) + .map_err(|report| { + report.attach( + diagnostic::Failure::new(diagnostic::Code::DigestMismatch) + .binaries(&left[PACKAGE], &right[PACKAGE]) + .package(), + ) + })?; + } + if left[PACKAGE] != right[PACKAGE] { + let offset = left[PACKAGE] + .iter() + .zip(&right[PACKAGE]) + .position(|(left, right)| left != right) + .unwrap_or(left[PACKAGE].len().min(right[PACKAGE].len())); + return Err(Report::new(ReleaseError::RebuildMismatch) + .attach("ordinary deployment package final-byte mismatch") + .attach( + diagnostic::Failure::mismatch(&left[PACKAGE], &right[PACKAGE], offset).package(), + )); + } + let package = left + .remove(PACKAGE) + .ok_or_else(|| Report::new(ReleaseError::Document))?; + let spdx_name = format!("{ASSET}.spdx.json"); + let reproduction_spdx_sha256 = hex::encode(Sha256::digest(&right[&spdx_name])); + // Exact ZIP membership is already checked. SPDX semantics remain enforced + // by the existing producer/signing/publication path, not by this receipt. + let binary = left + .remove(ASSET) + .ok_or_else(|| Report::new(ReleaseError::Document))?; + let spdx = left + .remove(&spdx_name) + .ok_or_else(|| Report::new(ReleaseError::Document))?; + Ok(Checked { + bindings, + equality, + binary, + package, + spdx, + reproduction_spdx_sha256, + }) +} + +#[cfg(test)] +pub(super) mod tests; diff --git a/crates/trusted-server-release/src/ordinary/transfer/tests.rs b/crates/trusted-server-release/src/ordinary/transfer/tests.rs new file mode 100644 index 0000000000..3b49c2fa6a --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/transfer/tests.rs @@ -0,0 +1,415 @@ +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::{Archives, Checked, check}; +use crate::ordinary::archive::tests::zip; +use crate::ordinary::{ASSET, run, tests}; + +const WASM: &[u8] = crate::ordinary::normalization::TEST_WASM; + +#[test] +fn matched_receipt_binds_the_deployment_package() { + let fixture = Fixture::new(); + let checked = fixture + .checked() + .expect("should compare complete build inputs"); + assert_eq!( + checked.receipt()["package"]["asset"], + "trusted-server-fastly.tar.gz", + "should bind the deployable package as well as the wasm" + ); +} + +#[test] +fn rejects_changed_and_different_packages_even_when_wasm_matches() { + for observed_change in [false, true] { + let mut fixture = Fixture::new(); + let changed = b"different package"; + fixture.archives[2] = zip( + &[ + (ASSET, WASM), + (&format!("{ASSET}.spdx.json"), b"{}"), + (crate::ordinary::PACKAGE, changed), + ], + false, + ); + if observed_change { + let mut observed = reproduction_observation(); + observed["package"] = + json!({"size":changed.len(),"sha256":hex::encode(Sha256::digest(changed))}); + fixture.archives[3] = zip(&[("fastly-observation.json", &encoded(&observed))], false); + } + fixture.refresh(); + let error = fixture.error(); + assert!( + error.contains(if observed_change { + "deployment package final-byte mismatch" + } else { + "deployment package changed after observation" + }), + "should reject the package at its own guard: {error}" + ); + } +} + +#[test] +fn rejects_a_missing_deployment_package() { + let mut fixture = Fixture::new(); + fixture.archives[2] = zip( + &[(ASSET, WASM), (&format!("{ASSET}.spdx.json"), b"{}")], + false, + ); + fixture.refresh(); + assert!( + fixture.error().contains("exact member count"), + "should require the package in both authenticated builds" + ); +} + +fn canonical_observation() -> Value { + with_artifact(tests::fixture()) +} + +fn reproduction_observation() -> Value { + with_artifact(tests::reproduction()) +} + +fn with_artifact(mut value: Value) -> Value { + value["artifact"] = json!({"size":WASM.len(),"sha256":hex::encode(Sha256::digest(WASM))}); + value +} + +fn encoded(value: &Value) -> Vec { + serde_json::to_vec(value).expect("should encode fixture") +} + +struct Fixture { + request: Value, + jobs: Value, + artifacts: Value, + archives: [Vec; 4], +} + +impl Fixture { + fn new() -> Self { + let build = zip( + &[ + (ASSET, WASM), + (crate::ordinary::PACKAGE, b"package"), + (&format!("{ASSET}.spdx.json"), b"{}"), + ], + false, + ); + let mut fixture = Self { + request: run::tests::request(), + jobs: run::tests::jobs(), + artifacts: run::tests::artifacts(), + archives: [ + build.clone(), + zip( + &[( + "fastly-observation.json", + &encoded(&canonical_observation()), + )], + false, + ), + build, + zip( + &[( + "fastly-observation.json", + &encoded(&reproduction_observation()), + )], + false, + ), + ], + }; + fixture.refresh(); + fixture + } + + fn refresh(&mut self) { + for (index, bytes) in self.archives.iter().enumerate() { + let digest = hex::encode(Sha256::digest(bytes)); + let role = if index < 2 { + "canonical" + } else { + "reproduction" + }; + let key = if index % 2 == 0 { + "artifact_digest" + } else { + "observation_digest" + }; + self.request[role][key] = json!(digest); + self.artifacts["artifacts"][index]["digest"] = json!(format!("sha256:{digest}")); + self.artifacts["artifacts"][index]["size_in_bytes"] = json!(bytes.len()); + } + } + + fn checked(&self) -> Result> { + let bindings = run::bind( + &encoded(&self.request), + &encoded(&self.jobs), + &encoded(&self.artifacts), + )?; + check( + bindings, + &Archives(self.archives.each_ref().map(Vec::as_slice)), + ) + } + + fn error(&self) -> String { + match self.checked() { + Ok(_) => panic!("should reject the altered transfer fixture"), + Err(error) => format!("{error:?}"), + } + } +} + +pub(in crate::ordinary) fn checked_fixture() -> Checked { + Fixture::new() + .checked() + .expect("should construct an unqualified ordinary test comparison") +} + +pub(in crate::ordinary) fn command_fixture( + source: &std::path::Path, + adjust: impl Fn(&mut Value), +) -> ([Vec; 3], [Vec; 4]) { + let mut fixture = Fixture::new(); + let identity = crate::ordinary::inputs::source_identity(source, &"a".repeat(40)) + .expect("should derive fixture recipe from independent comparison source"); + for (index, mut observed) in [ + (1, canonical_observation()), + (3, reproduction_observation()), + ] { + observed["recipe_sha256"] = json!(identity.recipe_sha256); + observed["cargo_lock_sha256"] = json!(identity.cargo_lock_sha256); + observed["npm_lock_sha256"] = json!(identity.npm_lock_sha256); + observed["tools"]["rustc"]["version"] = json!("rustc 1.98.1 (abcdef123 2026-01-01)"); + adjust(&mut observed); + fixture.archives[index] = zip(&[("fastly-observation.json", &encoded(&observed))], false); + } + fixture.refresh(); + ( + [ + encoded(&fixture.request), + encoded(&fixture.jobs), + encoded(&fixture.artifacts), + ], + fixture.archives, + ) +} + +#[test] +fn joins_exact_archives_and_emits_only_an_ordinary_matched_receipt() { + let receipt = Fixture::new() + .checked() + .expect("should join and compare exact retained bytes") + .receipt(); + assert_eq!( + receipt["outcome"], "matched", + "should record the actual comparison" + ); + assert!( + receipt.get("rustflags_sha256").is_none(), + "should keep the physical argument digest private and preserve the public v1 fields" + ); + assert_eq!( + receipt["release_qualification"], false, + "should never imply controlled qualification" + ); + assert_eq!( + receipt["canonical"]["job_id"], 100, + "should retain the authenticated-context join" + ); + assert_eq!( + receipt["canonical"]["artifact"]["id"], 200, + "should retain the direct upload identity" + ); + assert_eq!( + receipt["reproduction"]["observation_artifact"]["id"], 203, + "should retain all four transfer identities" + ); + assert_eq!( + receipt["canonical_sha256"], + hex::encode(Sha256::digest(WASM)), + "should bind the complete actual binary digest" + ); +} + +#[test] +fn rejects_changed_whole_archives_before_zip_parsing() { + for index in 0..4 { + let mut fixture = Fixture::new(); + fixture.archives[index][0] ^= 1; + assert!( + fixture.error().contains("archive digest or size"), + "should check service/upload bytes before parsing ZIP {index}" + ); + } +} + +#[test] +fn rejects_archive_length_disagreeing_with_service_metadata() { + let mut fixture = Fixture::new(); + fixture.artifacts["artifacts"][0]["size_in_bytes"] = json!(1); + assert!( + fixture.error().contains("archive digest or size"), + "should bind compressed archive length as well as digest" + ); +} + +#[test] +fn rejects_role_swaps_even_with_valid_archive_digests() { + let mut fixture = Fixture::new(); + for job in fixture.jobs["jobs"] + .as_array_mut() + .expect("should list jobs") + { + job["started_at"] = json!("2023-11-14T22:13:00Z"); + job["completed_at"] = json!("2023-11-14T22:29:00Z"); + } + fixture.archives.swap(1, 3); + fixture.refresh(); + assert!( + fixture.error().contains("canonical then reproduction"), + "should validate retained observation roles after verifying each archive" + ); +} + +#[test] +fn rejects_observed_source_disagreeing_with_current_run() { + let mut fixture = Fixture::new(); + for (index, mut observation) in [ + (1, canonical_observation()), + (3, reproduction_observation()), + ] { + observation["source_commit"] = json!("f".repeat(40)); + fixture.archives[index] = zip( + &[("fastly-observation.json", &encoded(&observation))], + false, + ); + } + fixture.refresh(); + assert!( + fixture.error().contains("observation source or start"), + "should reject equally forged source observations against current-run identity" + ); +} + +#[test] +fn rejects_observed_start_outside_its_producing_job() { + let mut fixture = Fixture::new(); + let mut observation = reproduction_observation(); + observation["environment"]["started_at_seconds"] = json!(1_700_000_000); + fixture.archives[3] = zip( + &[("fastly-observation.json", &encoded(&observation))], + false, + ); + fixture.refresh(); + assert!( + fixture.error().contains("observation source or start"), + "should join observed time to the current producing job window" + ); +} + +#[test] +fn rejects_canonical_observed_start_after_its_job_completed() { + let mut fixture = Fixture::new(); + let mut observation = canonical_observation(); + observation["environment"]["started_at_seconds"] = json!(1_700_000_011); + fixture.archives[1] = zip( + &[("fastly-observation.json", &encoded(&observation))], + false, + ); + fixture.refresh(); + assert!( + fixture.error().contains("observation source or start"), + "should reject an observed start after its producing job completed" + ); +} + +#[test] +fn rejects_equal_transferred_bytes_that_retain_a_physical_root() { + let root = b"/example/workspace/ordinary-canonical-source/src/lib.rs"; + let section = [ + 0, + u8::try_from(root.len() + 4).expect("should fit one LEB byte"), + 3, + ]; + let bytes = [WASM, §ion, b"raw", root].concat(); + let build = zip( + &[ + (ASSET, &bytes), + (crate::ordinary::PACKAGE, b"package"), + (&format!("{ASSET}.spdx.json"), b"{}"), + ], + false, + ); + let mut fixture = Fixture::new(); + fixture.archives[0] = build.clone(); + fixture.archives[2] = build; + for (index, mut observation) in [ + (1, canonical_observation()), + (3, reproduction_observation()), + ] { + observation["artifact"] = + json!({"size": bytes.len(), "sha256": hex::encode(Sha256::digest(&bytes))}); + fixture.archives[index] = zip( + &[("fastly-observation.json", &encoded(&observation))], + false, + ); + } + fixture.refresh(); + assert!( + fixture.error().contains("unremapped path prefix"), + "should scan both transferred binaries for physical roots before sealing" + ); +} + +#[test] +fn rejects_binary_mutation_after_observation() { + let mut fixture = Fixture::new(); + fixture.archives[2] = zip( + &[ + (ASSET, b"different"), + (crate::ordinary::PACKAGE, b"package"), + (&format!("{ASSET}.spdx.json"), b"{}"), + ], + false, + ); + fixture.refresh(); + assert!( + fixture + .error() + .contains("executable changed after observation"), + "should check final bytes against the transferred measurement" + ); +} + +#[test] +fn rejects_real_mismatch_even_when_both_observations_are_accurate() { + let mut fixture = Fixture::new(); + let bytes = [WASM, b"\0\x02\x01x"].concat(); + fixture.archives[2] = zip( + &[ + (ASSET, &bytes), + (crate::ordinary::PACKAGE, b"package"), + (&format!("{ASSET}.spdx.json"), b"{}"), + ], + false, + ); + let mut observation = reproduction_observation(); + observation["artifact"] = + json!({"size":bytes.len(), "sha256":hex::encode(Sha256::digest(&bytes))}); + fixture.archives[3] = zip( + &[("fastly-observation.json", &encoded(&observation))], + false, + ); + fixture.refresh(); + assert!( + fixture.error().contains("final-byte mismatch"), + "should reject actual nondeterminism without exclusions" + ); +} diff --git a/crates/trusted-server-release/src/package.rs b/crates/trusted-server-release/src/package.rs new file mode 100644 index 0000000000..9e0478b590 --- /dev/null +++ b/crates/trusted-server-release/src/package.rs @@ -0,0 +1,449 @@ +//! Read-only inspection of one manual Fastly transport envelope. +//! +//! The initial deployment profile accepts only the canonical two-line manifest +//! (`manifest_version = 3` and a portable package name). It intentionally excludes +//! every other field, table and comment. Service selection and observed resource +//! bindings belong to the independently approved manual deployment procedure. + +use std::collections::BTreeSet; +use std::fs::{self, File, Metadata, OpenOptions}; +use std::io::{self, BufReader, Read, Seek as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use flate2::bufread::GzDecoder; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::documents::{Role, read_bounded}; +use crate::error::ReleaseError; +use crate::inspect::inspect_wasm; + +/// Fixed service-neutral manifest included in every release package. +pub(crate) const RELEASE_MANIFEST: &[u8] = include_bytes!("../../../scripts/release/fastly.toml"); + +/// Constructs the normalized ustar/gzip bytes, independent of host metadata. +/// +/// # Errors +/// +/// Rejects an invalid module, oversized inputs or an archive encoding failure. +pub(crate) fn release_bytes(wasm: &[u8]) -> Result, Report> { + if wasm.is_empty() || wasm.len() as u64 > MODULE_LIMIT { + return Err(rejected("release package module exceeds its bound")); + } + inspect_wasm(wasm)?; + let name = manifest_name(RELEASE_MANIFEST)?; + let encoder = flate2::GzBuilder::new() + .mtime(0) + .operating_system(255) + .write(Vec::new(), flate2::Compression::best()); + let mut archive = tar::Builder::new(encoder); + for (path, directory, bytes) in [ + (format!("{name}/"), true, &b""[..]), + (format!("{name}/bin/"), true, &b""[..]), + (format!("{name}/bin/main.wasm"), false, wasm), + (format!("{name}/fastly.toml"), false, RELEASE_MANIFEST), + ] { + let mut header = tar::Header::new_ustar(); + header.set_entry_type(if directory { + tar::EntryType::Directory + } else { + tar::EntryType::Regular + }); + header.set_mode(if directory { 0o755 } else { 0o644 }); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_size(bytes.len() as u64); + header.set_path(path).map_err(rejected)?; + header.set_cksum(); + archive.append(&header, bytes).map_err(rejected)?; + } + archive + .into_inner() + .map_err(rejected)? + .finish() + .map_err(rejected) +} + +/// Writes a fresh deterministic release package and verifies its exact readback. +/// +/// # Errors +/// +/// Rejects unsafe input, existing output, or any persistence/readback failure. +pub(crate) fn build_package(wasm: &Path, output: &Path) -> Result<(), Report> { + let input = read_bounded(wasm, Role::Executable)?; + let bytes = release_bytes(&input.bytes)?; + let parent = output + .parent() + .ok_or_else(|| rejected("package needs a parent"))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(rejected)?; + std::io::Write::write_all(&mut temporary, &bytes).map_err(rejected)?; + temporary.as_file().sync_all().map_err(rejected)?; + temporary.persist_noclobber(output).map_err(rejected)?; + let retained = read_bounded(output, Role::ArtifactArchive)?; + if retained.bytes != bytes || read_bounded(wasm, Role::Executable)?.bytes != input.bytes { + return Err(rejected("release package changed before handoff")); + } + Ok(()) +} + +const ARCHIVE_LIMIT: u64 = 768 * 1024 * 1024; +const MODULE_LIMIT: u64 = 512 * 1024 * 1024; +const MANIFEST_LIMIT: u64 = 1024 * 1024; +const EXPANDED_LIMIT: u64 = MODULE_LIMIT + MANIFEST_LIMIT + 16 * 1024; + +fn rejected(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Inspect).attach(message.to_string()) +} + +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn approved_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn diagnostic_path(path: &Path) -> Result> { + let path = path + .to_str() + .ok_or_else(|| rejected("selected package path must be UTF-8 for diagnostics"))?; + Ok(json!(path)) +} + +fn manifest_name(bytes: &[u8]) -> Result> { + let text = std::str::from_utf8(bytes).map_err(rejected)?; + let table: toml::Table = toml::from_str(text).map_err(rejected)?; + let name = table + .get("name") + .and_then(toml::Value::as_str) + .ok_or_else(|| rejected("deployment manifest requires a package name"))?; + let valid = !name.is_empty() + && name.len() <= 64 + && name != "package" + && name.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }); + if !valid || bytes != format!("manifest_version = 3\nname = \"{name}\"\n").as_bytes() { + return Err(rejected( + "deployment manifest must match the canonical two-line version-3 profile and portable nonreserved name", + )); + } + Ok(name.to_owned()) +} + +#[derive(PartialEq, Eq)] +struct Stamp { + device: u64, + inode: u64, + links: u64, + size: u64, + mode: u32, + modified: (i64, i64), + changed: (i64, i64), +} + +impl From<&Metadata> for Stamp { + fn from(metadata: &Metadata) -> Self { + Self { + device: metadata.dev(), + inode: metadata.ino(), + links: metadata.nlink(), + size: metadata.len(), + mode: metadata.mode(), + modified: (metadata.mtime(), metadata.mtime_nsec()), + changed: (metadata.ctime(), metadata.ctime_nsec()), + } + } +} + +fn check_path(path: &Path, expected: &Stamp) -> Result<(), Report> { + let metadata = fs::symlink_metadata(path).map_err(rejected)?; + if !metadata.is_file() || Stamp::from(&metadata) != *expected { + return Err(rejected( + "approved manifest inode, path or metadata changes", + )); + } + Ok(()) +} + +struct RetainedArchive { + path: PathBuf, + file: File, + stamp: Stamp, +} + +impl RetainedArchive { + fn open(path: PathBuf) -> Result> { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(&path) + .map_err(rejected)?; + let metadata = file.metadata().map_err(rejected)?; + if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > ARCHIVE_LIMIT { + return Err(rejected( + "archive must be a bounded regular file with one hard link", + )); + } + let retained = Self { + path, + file, + stamp: Stamp::from(&metadata), + }; + retained.check()?; + Ok(retained) + } + + fn check(&self) -> Result<(), Report> { + let path = fs::symlink_metadata(&self.path).map_err(rejected)?; + let held = self.file.metadata().map_err(rejected)?; + if !path.is_file() || Stamp::from(&path) != self.stamp || Stamp::from(&held) != self.stamp { + return Err(rejected("retained archive inode, path or metadata changes")); + } + Ok(()) + } + + fn hash(&mut self) -> Result> { + self.check()?; + self.file.rewind().map_err(rejected)?; + let mut hash = Sha256::new(); + let mut count = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = self.file.read(&mut buffer).map_err(rejected)?; + if read == 0 { + break; + } + count += read as u64; + if count > self.stamp.size || count > ARCHIVE_LIMIT { + return Err(rejected("archive grows while hashing")); + } + hash.update(&buffer[..read]); + } + if count != self.stamp.size { + return Err(rejected("archive size changes while hashing")); + } + self.check()?; + Ok(hex::encode(hash.finalize())) + } +} + +struct Expanded { + reader: R, + count: u64, +} + +impl Read for Expanded { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let maximum = buffer.len().min(64 * 1024); + let count = self.reader.read(&mut buffer[..maximum])?; + self.count += count as u64; + if self.count > EXPANDED_LIMIT { + return Err(io::Error::other("package exceeds expanded byte bound")); + } + Ok(count) + } +} + +fn raw_field(bytes: &[u8]) -> Result<&str, Report> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + if bytes[end..].iter().any(|byte| *byte != 0) { + return Err(rejected("TAR field hides bytes after its terminator")); + } + std::str::from_utf8(&bytes[..end]).map_err(rejected) +} + +fn module_member( + retained: &mut RetainedArchive, + name: &str, + manifest: &[u8], +) -> Result, Report> { + retained.check()?; + retained.file.rewind().map_err(rejected)?; + let decoded = Expanded { + reader: GzDecoder::new(BufReader::new(&mut retained.file)), + count: 0, + }; + let mut archive = tar::Archive::new(decoded); + let root = format!("{name}/"); + let bin = format!("{name}/bin/"); + let bare_bin = format!("{name}/bin"); + let module_name = format!("{name}/bin/main.wasm"); + let manifest_name = format!("{name}/fastly.toml"); + let mut seen = BTreeSet::new(); + let mut module = Vec::new(); + for entry in archive.entries().map_err(rejected)?.raw(true) { + let mut entry = entry.map_err(rejected)?; + if seen.len() >= 4 { + return Err(rejected("package contains extra entries")); + } + let header = entry.header(); + let path = raw_field(&header.as_old().name)?.to_owned(); + // Only the two known directory entries may omit their trailing slash. + // Canonical logical IDs reject duplicates across those raw spellings; + // file paths, links, prefixes and metadata remain exact. + let directory = if path == name || path == root { + Some(root.as_str()) + } else if path == bare_bin || path == bin { + Some(bin.as_str()) + } else { + None + }; + if header + .as_ustar() + .is_some_and(|ustar| ustar.prefix.iter().any(|byte| *byte != 0)) + || !raw_field(&header.as_old().linkname)?.is_empty() + || !seen.insert(directory.unwrap_or(&path).to_owned()) + { + return Err(rejected( + "package has prefixed paths, links or duplicate entries", + )); + } + let kind = header.entry_type().as_byte(); + let size = entry.size(); + if directory.is_some() { + if kind != b'5' || size != 0 { + return Err(rejected( + "package directory has the wrong type or nonzero size", + )); + } + } else if path == module_name || path == manifest_name { + if !matches!(kind, b'0' | 0) { + return Err(rejected("package member is not a plain regular file")); + } + let limit = if path == module_name { + MODULE_LIMIT + } else { + MANIFEST_LIMIT + }; + if size > limit { + return Err(rejected("package member exceeds its byte bound")); + } + let mut body = Vec::new(); + body.try_reserve_exact(usize::try_from(size).map_err(rejected)?) + .map_err(rejected)?; + entry.read_to_end(&mut body).map_err(rejected)?; + if body.len() as u64 != size { + return Err(rejected("package member is truncated")); + } + if path == module_name { + module = body; + } else if body != manifest { + return Err(rejected( + "package manifest differs from the approved manifest bytes", + )); + } + } else { + return Err(rejected( + "package contains an unexpected raw path or metadata header", + )); + } + } + if seen.len() != 4 { + return Err(rejected("package must contain all four exact members")); + } + let mut decoded = archive.into_inner(); + let mut tail = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = decoded.read(&mut buffer).map_err(rejected)?; + if count == 0 { + break; + } + tail += count as u64; + if buffer[..count].iter().any(|byte| *byte != 0) { + return Err(rejected("TAR hides data after its terminator")); + } + } + if tail < 512 || !tail.is_multiple_of(512) { + return Err(rejected("TAR lacks complete terminator blocks")); + } + let mut compressed = decoded.reader.into_inner(); + if compressed.read(&mut buffer[..1]).map_err(rejected)? != 0 { + return Err(rejected("gzip has additional members or trailing bytes")); + } + drop(compressed); + retained.check()?; + Ok(module) +} + +/// Inspects a named manual package against independently approved identities. +/// +/// This diagnostic supplies no release-verification, runtime or deployment +/// authority. Call it again immediately before the separate submission. +/// +/// # Errors +/// +/// Rejects unsafe files, changed bytes, invalid expected hashes, noncanonical +/// manifests, invalid core Wasm, or any archive outside the bounded four-member +/// profile. Expected hashes must come from the successful independent release +/// verification and context procedure, never from this package. +pub(crate) fn inspect_package( + package_dir: &Path, + manifest: &Path, + expected_manifest_sha256: &str, + expected_wasm_sha256: &str, +) -> Result> { + if !approved_digest(expected_manifest_sha256) || !approved_digest(expected_wasm_sha256) { + return Err(rejected( + "expected SHA-256 identities must be lowercase 64-digit hex", + )); + } + let manifest_stamp = Stamp::from(&fs::symlink_metadata(manifest).map_err(rejected)?); + let approved = read_bounded(manifest, Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Inspect))?; + check_path(manifest, &manifest_stamp)?; + if approved.sha256 != expected_manifest_sha256 { + return Err(rejected( + "deployment manifest differs from its independently approved digest", + )); + } + let name = manifest_name(&approved.bytes)?; + let directory = fs::canonicalize(package_dir).map_err(rejected)?; + let mut retained = RetainedArchive::open(directory.join(format!("{name}.tar.gz")))?; + let archive_sha256 = retained.hash()?; + let module = module_member(&mut retained, &name, &approved.bytes)?; + // Authenticate the retained bytes before the Wasm parser sees them, so an + // unapproved module cannot drive parser allocations. + if digest(&module) != expected_wasm_sha256 { + return Err(rejected( + "actual package module differs from independently verified executable", + )); + } + inspect_wasm(&module)?; + drop(module); + let rechecked_module = module_member(&mut retained, &name, &approved.bytes)?; + if digest(&rechecked_module) != expected_wasm_sha256 || retained.hash()? != archive_sha256 { + return Err(rejected( + "retained archive or actual module changes before acceptance", + )); + } + let rechecked_manifest = read_bounded(manifest, Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Inspect))?; + if rechecked_manifest.bytes != approved.bytes { + return Err(rejected("approved manifest changes before acceptance")); + } + check_path(manifest, &manifest_stamp)?; + retained.check()?; + let package_path = diagnostic_path(&retained.path)?; + Ok( + json!({ "check": "manual-fastly-package", "accepted": true, "package_path": package_path, + "archive_sha256": archive_sha256, "module_sha256": expected_wasm_sha256, "manifest_sha256": approved.sha256, + "limits": { "runtime_validated": false, "official_release_verified": false, "deployment_authorized": false } }), + ) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/package/tests.rs b/crates/trusted-server-release/src/package/tests.rs new file mode 100644 index 0000000000..13bc525e45 --- /dev/null +++ b/crates/trusted-server-release/src/package/tests.rs @@ -0,0 +1,558 @@ +use std::fs; +use std::io::Write as _; +use std::os::unix::ffi::OsStringExt as _; +use std::os::unix::fs::symlink; + +use flate2::{Compression, write::GzEncoder}; +use sha2::Sha256; + +use super::*; + +const MANIFEST: &[u8] = b"manifest_version = 3\nname = \"example-package\"\n"; +const WASM: &[u8] = b"\0asm\x01\0\0\0"; + +#[test] +fn release_archive_has_only_fixed_paths_bytes_and_metadata() { + let bytes = release_bytes(WASM).expect("should build normalized archive"); + let decoded = flate2::read::GzDecoder::new(bytes.as_slice()); + let mut archive = tar::Archive::new(decoded); + let expected = [ + "trusted-server-fastly/", + "trusted-server-fastly/bin/", + "trusted-server-fastly/bin/main.wasm", + "trusted-server-fastly/fastly.toml", + ]; + let mut count = 0; + for (index, entry) in archive + .entries() + .expect("should read tar entries") + .enumerate() + { + let mut entry = entry.expect("should decode entry"); + assert_eq!( + entry.path_bytes(), + expected[index].as_bytes(), + "should fix member order and path" + ); + let header = entry.header(); + assert_eq!( + header.uid().expect("should read uid"), + 0, + "should normalize owner" + ); + assert_eq!( + header.gid().expect("should read gid"), + 0, + "should normalize group" + ); + assert_eq!( + header.mtime().expect("should read time"), + 0, + "should normalize timestamp" + ); + assert_eq!( + header.mode().expect("should read mode"), + if index < 2 { 0o755 } else { 0o644 }, + "should normalize permissions" + ); + let mut body = Vec::new(); + entry + .read_to_end(&mut body) + .expect("should read member bytes"); + assert_eq!( + body, + match index { + 2 => WASM, + 3 => RELEASE_MANIFEST, + _ => b"", + }, + "should retain only fixed manifest and exact wasm" + ); + count += 1; + } + assert_eq!(count, 4, "should contain exactly four members"); +} + +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn archive(manifest: &[u8], wasm: &[u8]) -> Vec { + let mut archive = tar::Builder::new(Vec::new()); + for (name, directory, body) in [ + ("example-package/", true, &[][..]), + ("example-package/bin/", true, &[][..]), + ("example-package/bin/main.wasm", false, wasm), + ("example-package/fastly.toml", false, manifest), + ] { + let mut header = tar::Header::new_ustar(); + header.set_size(body.len() as u64); + header.set_mode(if directory { 0o755 } else { 0o644 }); + header.set_entry_type(if directory { + tar::EntryType::Directory + } else { + tar::EntryType::Regular + }); + header.set_cksum(); + archive + .append_data(&mut header, name, body) + .expect("should append package member"); + } + let tar = archive.into_inner().expect("should finish archive"); + let mut compressed = GzEncoder::new(Vec::new(), Compression::fast()); + compressed.write_all(&tar).expect("should compress archive"); + compressed.finish().expect("should finish gzip") +} + +#[test] +fn accepts_exact_named_package_with_independent_digests() { + let directory = tempfile::tempdir().expect("should create private fixture"); + let manifest = directory.path().join("fastly.toml"); + fs::write(&manifest, MANIFEST).expect("should write manifest"); + fs::write( + directory.path().join("example-package.tar.gz"), + archive(MANIFEST, WASM), + ) + .expect("should write package"); + fs::write( + directory.path().join("package.tar.gz"), + b"ignored generic archive", + ) + .expect("should write unrelated generic archive"); + let accepted = inspect_package( + directory.path(), + &manifest, + &digest(MANIFEST), + &digest(WASM), + ) + .expect("should accept exact package"); + assert_eq!( + accepted["module_sha256"], + digest(WASM), + "should identify actual module bytes" + ); +} + +#[test] +fn rejects_manifest_inode_substitution_even_when_bytes_match() { + let directory = tempfile::tempdir().expect("should create private fixture"); + let path = directory.path().join("fastly.toml"); + fs::write(&path, MANIFEST).expect("should write original manifest"); + let before = + Stamp::from(&fs::symlink_metadata(&path).expect("should inspect original manifest")); + let replacement = directory.path().join("replacement"); + fs::write(&replacement, MANIFEST).expect("should write replacement manifest"); + fs::rename(&replacement, &path).expect("should replace manifest path"); + assert!( + check_path(&path, &before).is_err(), + "should reject replacement with identical bytes" + ); +} + +fn check_archive(bytes: &[u8], expected_wasm: &str) -> Result> { + let directory = tempfile::tempdir().expect("should create private fixture"); + let manifest = directory.path().join("fastly.toml"); + fs::write(&manifest, MANIFEST).expect("should write manifest"); + fs::write(directory.path().join("example-package.tar.gz"), bytes) + .expect("should write archive"); + inspect_package( + directory.path(), + &manifest, + &digest(MANIFEST), + expected_wasm, + ) +} + +fn tar_bytes() -> Vec { + let compressed = archive(MANIFEST, WASM); + let mut decoded = Vec::new(); + GzDecoder::new(compressed.as_slice()) + .read_to_end(&mut decoded) + .expect("should decode fixture"); + decoded +} + +fn compress(tar: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(tar).expect("should compress mutated TAR"); + encoder.finish().expect("should finish mutated gzip") +} + +fn change_header(tar: &mut [u8], offset: usize, change: impl FnOnce(&mut tar::Header)) { + let mut header = tar::Header::new_ustar(); + header + .as_mut_bytes() + .copy_from_slice(&tar[offset..offset + 512]); + change(&mut header); + header.set_cksum(); + tar[offset..offset + 512].copy_from_slice(header.as_bytes()); +} + +#[test] +fn accepts_only_the_known_directory_names_with_optional_single_trailing_slashes() { + for root in ["example-package", "example-package/"] { + for bin in ["example-package/bin", "example-package/bin/"] { + let mut tar = tar_bytes(); + for (offset, path) in [(0, root), (512, bin)] { + change_header(&mut tar, offset, |header| { + header.as_old_mut().name.fill(0); + header.as_old_mut().name[..path.len()].copy_from_slice(path.as_bytes()); + }); + } + check_archive(&compress(&tar), &digest(WASM)) + .expect("should accept each exact zero-sized type-5 directory representation"); + } + } +} + +#[test] +fn rejects_directory_alias_duplicates_across_both_trailing_slash_forms() { + for directory in ["example-package", "example-package/bin"] { + let slash = format!("{directory}/"); + for (first, second) in [(directory, slash.as_str()), (slash.as_str(), directory)] { + let mut tar = tar_bytes(); + for (offset, path) in [(0, first), (512, second)] { + change_header(&mut tar, offset, |header| { + header.as_old_mut().name.fill(0); + header.as_old_mut().name[..path.len()].copy_from_slice(path.as_bytes()); + }); + } + let error = check_archive(&compress(&tar), &digest(WASM)) + .expect_err("should reject duplicate logical directories across raw spellings"); + assert!( + format!("{error:?}").contains("duplicate"), + "should detect the logical duplicate before missing-member checks" + ); + } + } +} + +#[test] +fn rejects_wrong_types_nonzero_sizes_and_other_directory_path_aliases() { + for (offset, path) in [(0, "example-package"), (512, "example-package/bin")] { + for kind in [ + 0, b'0', b'1', b'2', b'3', b'4', b'6', b'x', b'g', b'L', b'K', b'S', + ] { + let mut tar = tar_bytes(); + change_header(&mut tar, offset, |header| { + header.as_old_mut().name.fill(0); + header.as_old_mut().name[..path.len()].copy_from_slice(path.as_bytes()); + header.set_entry_type(tar::EntryType::new(kind)); + }); + assert!( + check_archive(&compress(&tar), &digest(WASM)).is_err(), + "should require directory type-5 for both new raw spellings" + ); + } + let mut tar = tar_bytes(); + change_header(&mut tar, offset, |header| { + header.as_old_mut().name.fill(0); + header.as_old_mut().name[..path.len()].copy_from_slice(path.as_bytes()); + header.set_size(1); + }); + assert!( + check_archive(&compress(&tar), &digest(WASM)).is_err(), + "should require zero-sized directory entries" + ); + } + for path in [ + "example-package//", + "example-package/./", + "./example-package", + "/example-package", + "example-package/bin//", + "example-package/./bin", + "example-package\\bin", + ] { + let mut tar = tar_bytes(); + change_header(&mut tar, 0, |header| { + header.as_old_mut().name.fill(0); + header.as_old_mut().name[..path.len()].copy_from_slice(path.as_bytes()); + }); + assert!( + check_archive(&compress(&tar), &digest(WASM)).is_err(), + "should reject every unapproved raw path spelling" + ); + } +} + +#[test] +fn rejects_manifest_only_generic_layout() { + let mut tar = tar_bytes(); + tar.drain(1024..2048); + assert!( + check_archive(&compress(&tar), &digest(WASM)).is_err(), + "should require module and all four entries" + ); +} + +#[test] +fn rejects_extra_members_and_duplicates() { + let mut tar = tar_bytes(); + let root = tar[..512].to_vec(); + tar.splice(3072..3072, root); + assert!( + check_archive(&compress(&tar), &digest(WASM)).is_err(), + "should reject swept-in extra directory" + ); + let mut duplicate = tar_bytes(); + duplicate.copy_within(0..512, 512); + assert!( + check_archive(&compress(&duplicate), &digest(WASM)).is_err(), + "should reject duplicate raw names" + ); +} + +#[test] +fn rejects_raw_path_aliases_and_traversal() { + for path in [ + "../main.wasm", + "/example-package/bin/main.wasm", + "example-package/./bin/main.wasm", + "example-package\\bin\\main.wasm", + ] { + let mut tar = tar_bytes(); + change_header(&mut tar, 1024, |header| { + header.as_old_mut().name.fill(0); + header.as_old_mut().name[..path.len()].copy_from_slice(path.as_bytes()); + }); + assert!( + check_archive(&compress(&tar), &digest(WASM)).is_err(), + "should reject unsafe raw member {path}" + ); + } +} + +#[test] +fn rejects_links_metadata_and_special_entries() { + for kind in *b"12346xgLKS" { + let mut tar = tar_bytes(); + change_header(&mut tar, 1024, |header| { + header.set_entry_type(tar::EntryType::new(kind)) + }); + assert!( + check_archive(&compress(&tar), &digest(WASM)).is_err(), + "should reject unsupported TAR type {kind}" + ); + } +} + +#[test] +fn rejects_changed_module_and_manifest_bytes() { + assert!( + check_archive(&archive(MANIFEST, WASM), &"0".repeat(64)).is_err(), + "should require independently verified module digest" + ); + let other = b"manifest_version = 3\nname = \"other\"\n"; + assert!( + check_archive(&archive(other, WASM), &digest(WASM)).is_err(), + "should compare exact approved manifest bytes" + ); +} + +#[test] +fn rejects_unapproved_module_before_inspection() { + let error = check_archive(&archive(MANIFEST, b"not-wasm"), &digest(WASM)) + .expect_err("should reject unapproved bytes before inspection"); + assert!( + format!("{error:?}").contains("actual package module differs"), + "should check the independently approved digest before parsing Wasm" + ); +} + +#[test] +fn rejects_malformed_and_component_modules_even_with_matching_hashes() { + for wasm in [ + &b"not-wasm"[..], + &b"\0asm\x0d\0\x01\0"[..], + &b"\0asm\x01\0\0\0\x01\x01\xff"[..], + ] { + assert!( + check_archive(&archive(MANIFEST, wasm), &digest(wasm)).is_err(), + "should require validated actual core Wasm" + ); + } +} + +#[test] +fn rejects_concatenated_gzip_trailing_bytes_and_bad_trailers() { + let original = archive(MANIFEST, WASM); + let mut concatenated = original.clone(); + concatenated.extend_from_slice(&original); + let mut trailing = original.clone(); + trailing.push(0); + let mut truncated = original.clone(); + truncated.truncate(truncated.len() - 4); + let mut bad_crc = original; + let last = bad_crc.len() - 8; + bad_crc[last] ^= 1; + for invalid in [concatenated, trailing, truncated, bad_crc] { + assert!( + check_archive(&invalid, &digest(WASM)).is_err(), + "should consume and validate exact single gzip member" + ); + } +} + +#[test] +fn rejects_hidden_tar_trailing_payload_and_missing_terminator() { + let mut hidden = tar_bytes(); + hidden.push(1); + assert!( + check_archive(&compress(&hidden), &digest(WASM)).is_err(), + "should reject hidden nonzero trailing data" + ); + let mut missing = tar_bytes(); + missing.truncate(3072 + 512); + assert!( + check_archive(&compress(&missing), &digest(WASM)).is_err(), + "should require two complete zero terminator blocks" + ); +} + +#[test] +fn rejects_oversized_members_and_nonzero_directories_before_allocation() { + for (offset, size) in [(0, 1), (1024, MODULE_LIMIT + 1), (2048, MANIFEST_LIMIT + 1)] { + let mut tar = tar_bytes(); + change_header(&mut tar, offset, |header| header.set_size(size)); + assert!( + check_archive(&compress(&tar), &digest(WASM)).is_err(), + "should reject declared size {size} at header {offset}" + ); + } + let mut expanded = Expanded { + reader: &[0_u8][..], + count: EXPANDED_LIMIT, + }; + assert!( + expanded.read(&mut [0_u8]).is_err(), + "should stop expansion at its bound" + ); +} + +#[test] +fn rejects_noncanonical_private_and_reserved_manifests() { + for manifest in [ + "manifest_version = 3\nname = \"\"\n", + "manifest_version = 3\nname = \"package\"\n", + "manifest_version = 3\nname = \"example_package\"\n", + "manifest_version = 3\nname = \"example--package\"\n", + "manifest_version = 3\nname = \"Example\"\n", + "manifest_version = 3\nname = \"example\"\n# private material\n", + "manifest_version = 3\nname = \"example\"\nservice_id = \"fictional-service\"\n", + "manifest_version = 3\nname = \"example\"\n[local_server]\n", + "manifest_version = 3\nname = \"example\"\n[scripts]\nbuild = \"false\"\n", + ] { + assert!( + manifest_name(manifest.as_bytes()).is_err(), + "should reject manifest outside narrow deployment profile" + ); + } + let long = format!("manifest_version = 3\nname = \"{}\"\n", "a".repeat(65)); + assert!( + manifest_name(long.as_bytes()).is_err(), + "should bound package name bytes" + ); +} + +#[test] +fn rejects_unsafe_archive_files_and_substitution() { + let directory = tempfile::tempdir().expect("should create private fixture"); + let original = directory.path().join("archive"); + fs::write(&original, archive(MANIFEST, WASM)).expect("should write archive"); + let alias = directory.path().join("alias"); + symlink(&original, &alias).expect("should create symlink fixture"); + assert!( + RetainedArchive::open(alias).is_err(), + "should reject leaf symlink" + ); + let retained = RetainedArchive::open(original.clone()).expect("should open regular archive"); + let replacement = directory.path().join("replacement"); + fs::write(&replacement, archive(MANIFEST, WASM)).expect("should write replacement"); + fs::rename(replacement, &original).expect("should replace path"); + assert!( + retained.check().is_err(), + "should reject replaced archive path" + ); + let hard = directory.path().join("hard"); + fs::hard_link(&original, &hard).expect("should create hard link fixture"); + assert!( + RetainedArchive::open(original.clone()).is_err(), + "should reject multiple hard links" + ); + fs::remove_file(hard).expect("should remove hard link"); + File::options() + .write(true) + .open(&original) + .expect("should open sparse fixture") + .set_len(ARCHIVE_LIMIT + 1) + .expect("should make oversized sparse fixture"); + assert!( + RetainedArchive::open(original).is_err(), + "should reject archive length before reading" + ); + assert!( + RetainedArchive::open(directory.path().to_owned()).is_err(), + "should reject directories" + ); +} + +#[test] +fn rejects_prefixed_hidden_and_corrupt_tar_headers() { + let mut prefixed = tar_bytes(); + change_header(&mut prefixed, 1024, |header| { + header + .as_ustar_mut() + .expect("should have USTAR header") + .prefix[0] = b'x'; + }); + let mut hidden = tar_bytes(); + change_header(&mut hidden, 1024, |header| { + header.as_old_mut().name[90] = b'x'; + }); + let mut corrupt = tar_bytes(); + corrupt[1024 + 148] ^= 1; + for tar in [prefixed, hidden, corrupt] { + assert!( + check_archive(&compress(&tar), &digest(WASM)).is_err(), + "should reject raw path prefixes, hidden bytes and bad checksums" + ); + } +} + +#[test] +fn rejects_invalid_expected_digest_syntax() { + for invalid in [ + String::new(), + "A".repeat(64), + "g".repeat(64), + "0".repeat(63), + ] { + assert!( + check_archive(&archive(MANIFEST, WASM), &invalid).is_err(), + "should require bounded lowercase SHA-256 identity" + ); + } +} + +#[test] +fn detects_changed_held_archive_contents() { + let directory = tempfile::tempdir().expect("should create private fixture"); + let path = directory.path().join("archive"); + fs::write(&path, archive(MANIFEST, WASM)).expect("should write archive"); + let retained = RetainedArchive::open(path.clone()).expect("should open original archive"); + fs::write(path, b"changed").expect("should mutate held inode"); + assert!( + retained.check().is_err(), + "should reject changed metadata on held inode" + ); +} + +#[test] +fn rejects_non_utf8_package_directory_without_panicking() { + let package = PathBuf::from(std::ffi::OsString::from_vec(vec![b'p', 0xff])); + assert!( + diagnostic_path(&package).is_err(), + "should reject non-UTF-8 diagnostic path without panic" + ); +} diff --git a/crates/trusted-server-release/src/process.rs b/crates/trusted-server-release/src/process.rs new file mode 100644 index 0000000000..588b7bcbaa --- /dev/null +++ b/crates/trusted-server-release/src/process.rs @@ -0,0 +1,612 @@ +//! Bounded child-process execution. +//! +//! Every child runs from an absolute program path with a fixed argument +//! vector, an explicit working directory and an explicit environment applied +//! after clearing the inherited one. Each child leads a new process group. +//! +//! # Group kill and reaping +//! +//! The supervisor observes the leader's exit with +//! `waitid(WEXITED | WNOHANG | WNOWAIT)`, which leaves the leader as an +//! unreaped zombie. While that zombie pins the process-group ID, the group is +//! sent `SIGKILL` — after a normal exit as well as on deadline, cancellation +//! or output cap — so the signal cannot reach a recycled group. Only then is +//! the leader reaped (polling, bounded by the kill grace, never a blocking +//! wait), and read-only `killpg(pgid, 0)` probes confirm disappearance. +//! +//! That pinning needs a waitable leader. An inherited ignored SIGCHLD (or an +//! `SA_NOCLDWAIT` set in this process; `exec` clears that flag) lets the +//! kernel reap the leader at exit, so the binary +//! restores the default SIGCHLD action at startup and every spawn fails with +//! [`ProcessFailure::UnwaitableChildren`] while children would be reaped +//! automatically. +//! +//! A residual pgid-reuse window remains after reaping: if every member is +//! already gone and the ID is recycled before confirmation, a probe can +//! observe the unrelated group and cause a bounded false cleanup failure. +//! Signal zero cannot kill that group. Destructive signalling occurs only +//! before reaping, while the leader's identity remains pinned. +//! +//! Pipes are drained concurrently under byte caps and every wait is bounded, +//! so a descendant that leaves the group (`setsid`, job control) while still +//! holding the output pipes is reported as +//! [`ProcessFailure::DescendantEscaped`] instead of hanging the producer. +//! +//! # Residuals delegated to the Task 7 OS boundary +//! +//! This module does **not** contain a descendant that calls `setsid()` and +//! closes its stdio (a daemon): it leaves no group membership or pipe for the +//! supervisor to observe, and the run reports only the leader's outcome. The +//! Task 7 boundary must provide that containment — on Linux a container +//! private PID namespace with an init process (`docker run --init`), whose +//! teardown kills every namespace member; on macOS the sandbox profile. +//! macOS has no subreaper, and this module deliberately does not change +//! process-wide state such as `PR_SET_CHILD_SUBREAPER`. +//! +//! # Platform notes +//! +//! - On Linux, signalling a zombie succeeds, so `killpg` keeps returning `0` +//! until init reaps reparented zombie descendants. Containers therefore need +//! a reaping init (`--init`); without one, runs fail with +//! [`ProcessFailure::GroupSurvived`] instead of passing silently. +//! Process-group members cannot be enumerated portably, so the supervisor +//! does not scan `/proc` to treat a zombie-only group as gone; it relies on +//! that reaping init. The Linux test fixtures read `/proc//stat` only +//! to treat an already-killed `Z`-state fixture pid as dead. +//! - XNU's `killpg` reports `EPERM` while a group holds only zombies, so +//! `EPERM` is retried within the kill grace and recorded with its real errno +//! only if it persists. +//! - A persistent genuine `EPERM` and a group that survives `SIGKILL` need +//! privileges or kernel states tests cannot arrange; those paths are +//! unit-tested through their decision functions only. +//! - Process and group IDs `<= 1` are never signalled: `0`, `-1` and `1` +//! would address the supervisor's own group, every process, or init. +//! +//! # Environment +//! +//! The environment denylist is a backstop, not the policy: the Task 7 +//! boundary and preparation callers move to per-tool environment allowlists. +//! Compiler and tool selection (`RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, +//! `CARGO_BUILD_RUSTC`, `CARGO_BUILD_RUSTDOC`, `CARGO_BUILD_RUSTFLAGS` and +//! the wrappers) is denied here; recipes may set compiler flags only through a +//! future reviewed API. +//! +//! Configuration redirectors need the same pinning. `HOME`, `CARGO_HOME` and +//! `XDG_CONFIG_HOME` are accepted here but choose where tools read +//! configuration; `RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `CARGO_BUILD_RUSTC` +//! and `CARGO_BUILD_RUSTDOC` select compiler flags and tools and are denied +//! here until that reviewed API exists. Callers' per-tool allowlists (Task 7 +//! boundary and preparation) must pin every such redirector to authenticated +//! private paths. +//! +//! A timeout is the [`ProcessOutcome::TimedOut`] outcome; errnos are recorded +//! only from the operating system and never synthesized. + +mod environment; +mod pipes; +mod redact; +mod supervise; + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::process::{CommandExt as _, ExitStatusExt as _}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; + +use error_stack::Report; + +use self::environment::{EnvironmentValue, is_credential}; +use self::pipes::{DrainedOutput, Pipes}; +use self::redact::{REDACTED, redact, redact_cut_tail, redacted_text}; +pub(crate) use self::supervise::establish_waitable_children; +use self::supervise::{KILL_GRACE, Watch, observe, reap_within, require_waitable_children, settle}; +use crate::error::ReleaseError; + +/// Maximum stderr bytes shown in a failure excerpt (display bound only). +const STDERR_EXCERPT_BYTES: usize = 4 * 1024; + +/// Required per-child limits; there are deliberately no defaults. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ProcessLimits { + /// Wall-clock deadline measured from spawn. + pub(crate) wall: Duration, + /// Maximum captured stdout bytes; exceeding it kills the group. + pub(crate) stdout_max: u64, + /// Maximum captured stderr bytes; exceeding it kills the group. + pub(crate) stderr_max: u64, +} + +/// A cancellation flag shared between the caller and running children. +#[derive(Clone, Debug, Default)] +pub(crate) struct Cancellation(Arc); + +impl Cancellation { + /// Requests cancellation of every child observing this flag. + pub(crate) fn cancel(&self) { + self.0.store(true, Ordering::SeqCst); + } + + /// Returns whether cancellation was requested. + #[must_use] + pub(crate) fn is_cancelled(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +} + +/// A captured child output stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum Stream { + #[display("stdout")] + Stdout, + #[display("stderr")] + Stderr, +} + +/// How a supervised child run ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum ProcessOutcome { + /// The leader exited normally with `code`. + #[display("exited with status {code}")] + Exited { code: i32 }, + /// The leader was terminated by `signal`. + #[display("was terminated by signal {signal}")] + Signaled { signal: i32 }, + /// The wall-clock deadline expired and the group was killed. + #[display("exceeded its wall-clock deadline")] + TimedOut, + /// Cancellation was requested and the group was killed (or never started). + #[display("was cancelled")] + Cancelled, + /// A stream exceeded its byte cap and the group was killed. + #[display("exceeded the {stream} byte cap")] + OutputLimit { stream: Stream }, +} + +/// Typed classification attached to every [`ReleaseError::Process`] report. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum ProcessFailure { + /// The command description was rejected before spawning. + #[display("invalid bounded command")] + InvalidCommand, + /// The operating system refused to start the child. + #[display("spawn failed (errno {errno:?})")] + Spawn { errno: Option }, + /// Observing or reaping the leader failed. + #[display("wait failed (errno {errno:?})")] + Wait { errno: Option }, + /// Group signalling or read-only observation failed outside `ESRCH`. + #[display("process-group signal failed (errno {errno:?})")] + Signal { errno: Option }, + /// The leader or group still existed after `SIGKILL` and the kill grace. + #[display("process group survived SIGKILL past the grace period")] + GroupSurvived, + /// A descendant left the group and still holds the child pipes. + #[display("a descendant escaped the process group while holding its pipes")] + DescendantEscaped, + /// Reading or writing a child pipe failed. + #[display("child pipe failed (errno {errno:?})")] + Pipe { errno: Option }, + /// The child ran but did not exit with status zero. + #[display("child did not succeed")] + Unsuccessful, + /// SIGCHLD handling would reap the leader before its group is killed. + #[display("SIGCHLD disposition reaps children automatically")] + UnwaitableChildren, +} + +/// A fully specified child command executed under [`ProcessLimits`]. +pub(crate) struct BoundedCommand { + program: PathBuf, + arguments: Vec, + working_directory: PathBuf, + environment: BTreeMap, + stdin: Option>, + limits: ProcessLimits, + cancellation: Option, +} + +fn process_error(failure: ProcessFailure, message: impl fmt::Display) -> Report { + Report::new(ReleaseError::Process) + .attach(failure) + .attach(message.to_string()) +} + +fn invalid(message: impl fmt::Display) -> Report { + process_error(ProcessFailure::InvalidCommand, message) +} + +impl BoundedCommand { + /// Describes a child running `program` in `working_directory`. + /// + /// The command starts with no arguments, an empty environment and a null + /// stdin. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] with [`ProcessFailure::InvalidCommand`] + /// when `program` or `working_directory` is not absolute. + pub(crate) fn new( + program: &Path, + working_directory: &Path, + limits: ProcessLimits, + ) -> Result> { + if !program.is_absolute() { + return Err(invalid("program path is not absolute")); + } + if !working_directory.is_absolute() { + return Err(invalid("working directory is not absolute")); + } + Ok(Self { + program: program.to_path_buf(), + arguments: Vec::new(), + working_directory: working_directory.to_path_buf(), + environment: BTreeMap::new(), + stdin: None, + limits, + cancellation: None, + }) + } + + /// Appends fixed arguments to the argument vector. + #[must_use] + pub(crate) fn arguments(mut self, arguments: I) -> Self + where + I: IntoIterator, + { + self.arguments.extend(arguments); + self + } + + /// Adds a non-secret environment variable. + /// + /// # Errors + /// + /// Returns [`ProcessFailure::InvalidCommand`] for an empty or malformed + /// name, a value containing NUL, a duplicate name, a code or configuration + /// injection variable (`LD_*`, `DYLD_*`, `NODE_OPTIONS`, `npm_config_*`, + /// Git configuration and interpreter search paths), or a known credential + /// name, which only [`Self::secret_environment`] may carry. + pub(crate) fn environment( + self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + if is_credential(name.as_ref()) { + return Err(invalid(format!( + "credential variable {:?} must be classified as secret", + name.as_ref() + ))); + } + self.insert_environment(name.as_ref(), value.as_ref(), false) + } + + /// Adds an environment variable whose value is redacted from all + /// diagnostics, including captured stderr excerpts. + /// + /// # Errors + /// + /// Returns [`ProcessFailure::InvalidCommand`] for the same reasons as + /// [`Self::environment`] except the credential rule, or for an empty + /// secret value. + pub(crate) fn secret_environment( + self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + if value.as_ref().is_empty() { + return Err(invalid(format!( + "secret environment variable {:?} is empty", + name.as_ref() + ))); + } + self.insert_environment(name.as_ref(), value.as_ref(), true) + } + + /// Supplies fixed in-memory bytes on stdin instead of `/dev/null`. + #[must_use] + pub(crate) fn stdin_bytes(mut self, bytes: Vec) -> Self { + self.stdin = Some(bytes); + self + } + + /// Observes `cancellation`; setting it kills and reaps the child group, + /// and a flag set before [`Self::run`] prevents spawning at all. + #[must_use] + pub(crate) fn cancellation(mut self, cancellation: Cancellation) -> Self { + self.cancellation = Some(cancellation); + self + } + + fn secrets(&self) -> Vec> { + self.environment + .values() + .filter(|entry| entry.secret) + .map(|entry| entry.value.as_bytes().to_vec()) + .collect() + } + + fn is_cancelled(&self) -> bool { + self.cancellation + .as_ref() + .is_some_and(Cancellation::is_cancelled) + } + + fn command(&self) -> Command { + let mut command = Command::new(&self.program); + command + .args(&self.arguments) + .current_dir(&self.working_directory) + .env_clear() + .envs( + self.environment + .iter() + .map(|(name, entry)| (name, &entry.value)), + ) + .stdin(if self.stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0); + + command + } + + fn result( + &self, + outcome: ProcessOutcome, + output: DrainedOutput, + started: Instant, + ) -> ProcessResult { + ProcessResult { + program: self.program.clone(), + outcome, + stdout: output.stdout, + stderr: output.stderr, + stderr_truncated: output.stderr_truncated, + elapsed: started.elapsed(), + secrets: self.secrets(), + } + } + + /// Spawns the child and returns it with its usable process-group ID. + fn spawn_group(&self) -> Result<(Child, libc::pid_t), Report> { + require_waitable_children()?; + let mut child = self.command().spawn().map_err(|source| { + process_error( + ProcessFailure::Spawn { + errno: source.raw_os_error(), + }, + format!("cannot spawn child: {source}"), + ) + })?; + if let Some(process_group) = libc::pid_t::try_from(child.id()) + .ok() + .filter(|pid| *pid > 1) + { + return Ok((child, process_group)); + } + // The ID is unusable for killpg, so only the leader can be stopped. + let mut report = process_error( + ProcessFailure::Spawn { errno: None }, + "child process ID is unusable as a process group", + ); + if let Err(source) = child.kill() { + report = report.attach(format!("cannot kill the child: {source}")); + } + match reap_within(&mut child, KILL_GRACE) { + Ok(Some(_)) => {} + Ok(None) => report = report.attach("child stayed unreaped after the kill grace"), + Err(source) => report = report.attach(format!("cannot reap the child: {source}")), + } + Err(report) + } + + /// Runs the child to an outcome under the configured limits. + /// + /// Deadlines, cancellation and output caps are outcomes, not errors: the + /// group is killed while the leader pins its ID, the leader is reaped and + /// the group confirmed gone before returning. After a normal leader exit + /// the group is killed as well so no same-group descendant outlives the + /// run. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] carrying a [`ProcessFailure`] when the + /// child cannot be spawned (with the real errno), the group cannot be + /// signalled, reaped or does not vanish within the kill grace, a pipe + /// fails, or a descendant escaped the group while holding the child + /// pipes. + pub(crate) fn run(&self) -> Result> { + let started = Instant::now(); + if self.is_cancelled() { + return Ok(self.result(ProcessOutcome::Cancelled, DrainedOutput::default(), started)); + } + let deadline = started + self.limits.wall; + let (mut child, process_group) = self.spawn_group()?; + let Some((pipes, overflow)) = Pipes::start(&mut child, self.stdin.clone(), &self.limits) + else { + settle(&mut child, process_group)?; + return Err(process_error( + ProcessFailure::Pipe { errno: None }, + "child pipes are unavailable", + )); + }; + let watch = Watch { + overflow: &overflow, + deadline, + cancellation: self.cancellation.as_ref(), + }; + let stop = observe(&mut child, process_group, &watch)?; + let status = settle(&mut child, process_group)?; + let drained = pipes.join()?; + let outcome = final_outcome(stop, &overflow, &drained, status)?; + Ok(self.result(outcome, drained, started)) + } +} + +/// Chooses the reported outcome once the group is gone and pipes are drained. +/// +/// A stop condition wins; otherwise a leader that died of `SIGPIPE` after a +/// capped drain closed its pipe still reports the cap, not its own exit. +fn final_outcome( + stop: Option, + overflow: &Receiver, + drained: &DrainedOutput, + status: ExitStatus, +) -> Result> { + if let Some(outcome) = stop { + return Ok(outcome); + } + match overflow.try_recv().ok().or(drained.overflowed) { + Some(stream) => Ok(ProcessOutcome::OutputLimit { stream }), + None => status_outcome(status), + } +} + +impl fmt::Debug for BoundedCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let secrets = self.secrets(); + let text = |bytes: &[u8]| redacted_text(bytes, &secrets); + let arguments: Vec = self + .arguments + .iter() + .map(|argument| text(argument.as_bytes())) + .collect(); + let environment: BTreeMap = self + .environment + .iter() + .map(|(name, entry)| { + let value = if entry.secret { + String::from_utf8_lossy(REDACTED).into_owned() + } else { + text(entry.value.as_bytes()) + }; + (text(name.as_bytes()), value) + }) + .collect(); + formatter + .debug_struct("BoundedCommand") + .field("program", &text(self.program.as_os_str().as_bytes())) + .field("arguments", &arguments) + .field( + "working_directory", + &text(self.working_directory.as_os_str().as_bytes()), + ) + .field("environment", &environment) + .field("stdin_bytes", &self.stdin.as_ref().map(Vec::len)) + .field("limits", &self.limits) + .field("cancellable", &self.cancellation.is_some()) + .finish() + } +} + +/// The bounded result of one supervised child run. +pub(crate) struct ProcessResult { + program: PathBuf, + outcome: ProcessOutcome, + stdout: Vec, + stderr: Vec, + /// Whether the stderr drain stopped at its cap, whatever the reported outcome. + stderr_truncated: bool, + elapsed: Duration, + secrets: Vec>, +} + +impl ProcessResult { + /// Returns how the run ended. + #[must_use] + pub(crate) fn outcome(&self) -> ProcessOutcome { + self.outcome + } + + /// Returns captured stdout, at most the configured cap. + #[must_use] + pub(crate) fn stdout(&self) -> &[u8] { + &self.stdout + } + + /// Returns captured stderr, at most the configured cap. + #[must_use] + pub(crate) fn stderr(&self) -> &[u8] { + &self.stderr + } + + /// Returns wall-clock time from spawn to a confirmed-gone group. + #[must_use] + pub(crate) fn elapsed(&self) -> Duration { + self.elapsed + } + + /// Returns stdout only when the child exited with status zero. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] with [`ProcessFailure::Unsuccessful`] + /// for any other outcome, carrying a bounded, lossy stderr tail with every + /// secret environment value redacted, including a secret prefix cut off by + /// the stderr cap. + pub(crate) fn require_success(self) -> Result, Report> { + if self.outcome == (ProcessOutcome::Exited { code: 0 }) { + return Ok(self.stdout); + } + let redacted = redact(&self.stderr, &self.secrets); + // Only a stderr cut by its cap can end inside a secret; the cut is + // tracked per stream because another stream may have overflowed first. + let redacted = if self.stderr_truncated { + redact_cut_tail(redacted, &self.secrets) + } else { + redacted + }; + let start = redacted.len().saturating_sub(STDERR_EXCERPT_BYTES); + let excerpt = String::from_utf8_lossy(redacted.get(start..).unwrap_or_default()); + Err(process_error( + ProcessFailure::Unsuccessful, + format!( + "{} {}; stderr tail ({} of {} bytes): {excerpt}", + redacted_text(self.program.as_os_str().as_bytes(), &self.secrets), + self.outcome, + redacted.len() - start, + redacted.len() + ), + )) + } +} + +impl fmt::Debug for ProcessResult { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProcessResult") + .field( + "program", + &redacted_text(self.program.as_os_str().as_bytes(), &self.secrets), + ) + .field("outcome", &self.outcome) + .field("stdout_bytes", &self.stdout.len()) + .field("stderr_bytes", &self.stderr.len()) + .field("elapsed", &self.elapsed) + .finish_non_exhaustive() + } +} + +fn status_outcome(status: ExitStatus) -> Result> { + match (status.code(), status.signal()) { + (Some(code), _) => Ok(ProcessOutcome::Exited { code }), + (None, Some(signal)) => Ok(ProcessOutcome::Signaled { signal }), + (None, None) => Err(process_error( + ProcessFailure::Wait { errno: None }, + format!("unrecognized child status {status:?}"), + )), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/process/environment.rs b/crates/trusted-server-release/src/process/environment.rs new file mode 100644 index 0000000000..4783ad4d27 --- /dev/null +++ b/crates/trusted-server-release/src/process/environment.rs @@ -0,0 +1,227 @@ +//! Environment validation for [`BoundedCommand`]. +//! +//! The denylists are a backstop; the Task 7 boundary and preparation callers +//! supply per-tool allowlists. + +use std::ffi::{OsStr, OsString}; +use std::os::unix::ffi::OsStrExt as _; + +use error_stack::Report; + +use super::{BoundedCommand, invalid}; +use crate::error::ReleaseError; + +/// Uppercased name prefixes that inject code or configuration into children. +const INJECTION_PREFIXES: [&str; 5] = [ + "DYLD_", + "GIT_CONFIG_KEY_", + "GIT_CONFIG_VALUE_", + "LD_", + "NPM_CONFIG_", +]; +/// Uppercased `CARGO_TARGET__` suffixes that select tools. +const CARGO_TARGET_TOOL_SUFFIXES: [&str; 2] = ["_LINKER", "_RUNNER"]; +/// Uppercased exact names that inject code or configuration into children. +const INJECTION_EXACT: [&str; 42] = [ + "BASH_ENV", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_ENCODED_RUSTFLAGS", + "EDITOR", + "ENV", + "GCONV_PATH", + "GIT_ASKPASS", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_SYSTEM", + "GIT_EDITOR", + "GIT_EXEC_PATH", + "GIT_EXTERNAL_DIFF", + "GIT_PAGER", + "GIT_PROXY_COMMAND", + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_TEMPLATE_DIR", + "JAVA_TOOL_OPTIONS", + "NODE_OPTIONS", + "NODE_PATH", + "PAGER", + "PERL5LIB", + "PERL5OPT", + "PYTHONPATH", + "PYTHONSTARTUP", + "RUBYLIB", + "RUBYOPT", + "RUSTC", + "RUSTC_WORKSPACE_WRAPPER", + "RUSTC_WRAPPER", + "RUSTDOC", + "RUSTFLAGS", + "SSH_ASKPASS", + "VISUAL", + "_JAVA_OPTIONS", +]; +/// Uppercased credential name prefixes accepted only as secrets. +const CREDENTIAL_PREFIXES: [&str; 2] = ["AWS_", "FASTLY_"]; +/// Uppercased `aws-lc-sys` system-library switch, also per target as +/// `_`. It shares the `AWS_` prefix but carries no credential. +const BUILD_SWITCH: &str = "AWS_LC_SYS_USE_SYSTEM"; +/// Uppercased `CARGO_REGISTRIES__TOKEN` registry credential affixes. +const CARGO_REGISTRIES_TOKEN: (&str, &str) = ("CARGO_REGISTRIES_", "_TOKEN"); +/// Uppercased credential names accepted only as secrets. +const CREDENTIAL_EXACT: [&str; 8] = [ + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_RUNTIME_TOKEN", + "CARGO_REGISTRY_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "NODE_AUTH_TOKEN", + "NPM_TOKEN", + "SIGSTORE_ID_TOKEN", +]; + +/// One explicit child environment entry. +#[derive(Clone)] +pub(super) struct EnvironmentValue { + pub(super) value: OsString, + pub(super) secret: bool, +} + +fn upper_name(name: &OsStr) -> String { + name.to_string_lossy().to_ascii_uppercase() +} + +fn is_injection(name: &OsStr) -> bool { + let upper = upper_name(name); + let cargo_target_tool = upper.starts_with("CARGO_TARGET_") + && CARGO_TARGET_TOOL_SUFFIXES + .iter() + .any(|suffix| upper.ends_with(suffix)); + cargo_target_tool + || INJECTION_PREFIXES + .iter() + .any(|prefix| upper.starts_with(prefix)) + || INJECTION_EXACT.contains(&upper.as_str()) +} + +pub(super) fn is_credential(name: &OsStr) -> bool { + let upper = upper_name(name); + let (registries, token) = CARGO_REGISTRIES_TOKEN; + let registry_token = upper.starts_with(registries) && upper.ends_with(token); + let build_switch = upper == BUILD_SWITCH + || upper + .strip_prefix(BUILD_SWITCH) + .and_then(|suffix| suffix.strip_prefix('_')) + .is_some_and(|triple| { + !triple.is_empty() + && triple + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }); + registry_token + || (!build_switch + && upper != "AWS_LC_SYS_CMAKE_BUILDER" + && upper != "AWS_LC_SYS_CMAKE_BUILDER_X86_64_UNKNOWN_LINUX_GNU" + && CREDENTIAL_PREFIXES + .iter() + .any(|prefix| upper.starts_with(prefix))) + || CREDENTIAL_EXACT.contains(&upper.as_str()) +} + +/// Exact names an orchestrator may set explicitly through +/// [`BoundedCommand::tool_selector_environment`] despite the injection denylist. +const TOOL_SELECTORS: [&str; 1] = ["RUSTC"]; + +impl BoundedCommand { + pub(super) fn insert_environment( + self, + name: &OsStr, + value: &OsStr, + secret: bool, + ) -> Result> { + self.insert_checked(name, value, secret, false) + } + + /// Adds one explicitly selected tool path, such as the compiler. + /// + /// The generic [`Self::environment`] keeps rejecting every tool selector, + /// because an inherited or attacker-chosen `RUSTC` swaps the compiler. + /// This method accepts only the names in [`TOOL_SELECTORS`], only with an + /// absolute path, and only once. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] for any other name, a relative or + /// empty value, or a repeated name. + pub(crate) fn tool_selector_environment( + self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + let (name, value) = (name.as_ref(), value.as_ref()); + let selectable = TOOL_SELECTORS + .iter() + .any(|selector| name == OsStr::new(selector)); + if !selectable || !value.as_bytes().starts_with(b"/") { + return Err(invalid(format!( + "{name:?} is not an explicitly selectable tool with an absolute path" + ))); + } + self.insert_checked(name, value, false, true) + } + + fn insert_checked( + mut self, + name: &OsStr, + value: &OsStr, + secret: bool, + tool_selector: bool, + ) -> Result> { + let name_bytes = name.as_bytes(); + if name_bytes.is_empty() || name_bytes.contains(&b'=') || name_bytes.contains(&0) { + return Err(invalid(format!( + "environment variable name {name:?} is malformed" + ))); + } + if value.as_bytes().contains(&0) { + return Err(invalid(format!( + "environment variable {name:?} value contains NUL" + ))); + } + if !tool_selector && is_injection(name) { + return Err(invalid(format!( + "environment variable {name:?} injects code or configuration" + ))); + } + if matches!( + upper_name(name).as_str(), + "AWS_LC_SYS_CMAKE_BUILDER" | "AWS_LC_SYS_CMAKE_BUILDER_X86_64_UNKNOWN_LINUX_GNU" + ) && value != OsStr::new("0") + { + return Err(invalid( + "only the reviewed AWS-LC cc backend switch is permitted", + )); + } + if self.environment.contains_key(name) { + return Err(invalid(format!( + "environment variable {name:?} is set twice" + ))); + } + self.environment.insert( + name.to_os_string(), + EnvironmentValue { + value: value.to_os_string(), + secret, + }, + ); + Ok(self) + } +} + +#[cfg(test)] +mod selector_tests; diff --git a/crates/trusted-server-release/src/process/environment/selector_tests.rs b/crates/trusted-server-release/src/process/environment/selector_tests.rs new file mode 100644 index 0000000000..6f7c025bac --- /dev/null +++ b/crates/trusted-server-release/src/process/environment/selector_tests.rs @@ -0,0 +1,74 @@ +//! Pure tests of the explicit tool-selector allowance; no process is started. + +use std::ffi::{OsStr, OsString}; +use std::path::Path; +use std::time::Duration; + +use super::*; +use crate::process::ProcessLimits; + +fn fresh() -> BoundedCommand { + BoundedCommand::new( + Path::new("/example/tool"), + Path::new("/example"), + ProcessLimits { + wall: Duration::from_secs(1), + stdout_max: 1024, + stderr_max: 1024, + }, + ) + .expect("should describe a command") +} + +#[test] +fn generic_environment_still_rejects_every_tool_selector() { + for name in [ + "RUSTC", + "RUSTC_WRAPPER", + "RUSTFLAGS", + "RUSTDOC", + "CARGO_BUILD_RUSTC", + ] { + assert!( + fresh().environment(name, "/example/rustc").is_err(), + "should keep rejecting {name} through the generic environment" + ); + } +} + +#[test] +fn only_rustc_with_an_absolute_path_is_a_selectable_tool() { + let selected = fresh() + .tool_selector_environment("RUSTC", "/example/bin/rustc") + .expect("should accept an explicit absolute RUSTC"); + assert_eq!( + selected + .environment + .get(OsStr::new("RUSTC")) + .map(|entry| entry.value.clone()), + Some(OsString::from("/example/bin/rustc")), + "should carry the selected compiler" + ); + for (name, value) in [ + ("RUSTC_WRAPPER", "/example/wrapper"), + ("RUSTFLAGS", "/example/flags"), + ("RUSTDOC", "/example/rustdoc"), + ("CARGO_BUILD_RUSTC", "/example/rustc"), + ("LD_PRELOAD", "/example/lib.so"), + ("rustc", "/example/rustc"), + ("RUSTC", "relative/rustc"), + ("RUSTC", ""), + ] { + assert!( + fresh().tool_selector_environment(name, value).is_err(), + "should reject {name}={value:?} as a tool selector" + ); + } + assert!( + fresh() + .tool_selector_environment("RUSTC", "/example/bin/rustc") + .and_then(|command| command.tool_selector_environment("RUSTC", "/example/other")) + .is_err(), + "should reject setting RUSTC twice" + ); +} diff --git a/crates/trusted-server-release/src/process/pipes.rs b/crates/trusted-server-release/src/process/pipes.rs new file mode 100644 index 0000000000..7d2df2e256 --- /dev/null +++ b/crates/trusted-server-release/src/process/pipes.rs @@ -0,0 +1,159 @@ +//! Bounded concurrent draining of child pipes. + +use std::io::{self, Read, Write as _}; +use std::process::Child; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::{self, JoinHandle}; +use std::time::Instant; + +use error_stack::Report; + +use super::supervise::{KILL_GRACE, POLL_INTERVAL}; +use super::{ProcessFailure, ProcessLimits, Stream, process_error}; +use crate::error::ReleaseError; + +/// Pipe read size for the drain threads. +const READ_CHUNK: usize = 64 * 1024; + +/// Bytes read from one stream before EOF or the cap. +struct Drained { + bytes: Vec, + overflowed: bool, +} + +/// Captured output of both streams after every pipe thread finished. +#[derive(Default)] +pub(super) struct DrainedOutput { + pub(super) stdout: Vec, + pub(super) stderr: Vec, + pub(super) overflowed: Option, + pub(super) stderr_truncated: bool, +} + +/// Drain and stdin-writer threads attached to one child. +pub(super) struct Pipes { + stdout: JoinHandle>, + stderr: JoinHandle>, + stdin: Option>>, +} + +impl Pipes { + pub(super) fn start( + child: &mut Child, + stdin_bytes: Option>, + limits: &ProcessLimits, + ) -> Option<(Self, Receiver)> { + let stdout = child.stdout.take()?; + let stderr = child.stderr.take()?; + let stdin = match stdin_bytes { + Some(bytes) => Some((child.stdin.take()?, bytes)), + None => None, + }; + let (sender, overflow) = mpsc::channel(); + let pipes = Self { + stdout: drain(stdout, Stream::Stdout, limits.stdout_max, sender.clone()), + stderr: drain(stderr, Stream::Stderr, limits.stderr_max, sender), + stdin: stdin.map(|(mut pipe, bytes)| { + thread::spawn(move || match pipe.write_all(&bytes) { + // A child that stops reading early is not a pipe failure. + Err(source) if source.kind() == io::ErrorKind::BrokenPipe => Ok(()), + other => other, + }) + }), + }; + Some((pipes, overflow)) + } + + fn finished(&self) -> bool { + self.stdout.is_finished() + && self.stderr.is_finished() + && self.stdin.as_ref().is_none_or(JoinHandle::is_finished) + } + + /// Joins every pipe thread within [`KILL_GRACE`]. + /// + /// Threads still blocked after the grace stay detached: they hold only a + /// pipe end that the escaped descendant keeps open. + pub(super) fn join(self) -> Result> { + let until = Instant::now() + KILL_GRACE; + while !self.finished() { + if Instant::now() >= until { + return Err(process_error( + ProcessFailure::DescendantEscaped, + "child pipes stayed open after the process group was gone; \ + output is rejected", + )); + } + thread::sleep(POLL_INTERVAL); + } + if let Some(stdin) = self.stdin { + joined(stdin)?; + } + let stdout = joined(self.stdout)?; + let stderr = joined(self.stderr)?; + let overflowed = if stdout.overflowed { + Some(Stream::Stdout) + } else if stderr.overflowed { + Some(Stream::Stderr) + } else { + None + }; + Ok(DrainedOutput { + stdout: stdout.bytes, + stderr: stderr.bytes, + overflowed, + stderr_truncated: stderr.overflowed, + }) + } +} + +fn joined(handle: JoinHandle>) -> Result> { + handle + .join() + .map_err(|_| process_error(ProcessFailure::Pipe { errno: None }, "pipe thread panicked"))? + .map_err(|source| { + process_error( + ProcessFailure::Pipe { + errno: source.raw_os_error(), + }, + format!("child pipe failed: {source}"), + ) + }) +} + +fn drain( + mut reader: R, + stream: Stream, + limit: u64, + overflow: Sender, +) -> JoinHandle> { + thread::spawn(move || { + let mut bytes = Vec::new(); + let mut buffer = vec![0; READ_CHUNK]; + loop { + let count = match reader.read(&mut buffer) { + Ok(count) => count, + Err(source) if source.kind() == io::ErrorKind::Interrupted => continue, + Err(source) => return Err(source), + }; + if count == 0 { + return Ok(Drained { + bytes, + overflowed: false, + }); + } + let captured = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let remaining = usize::try_from(limit.saturating_sub(captured)).unwrap_or(usize::MAX); + if count > remaining { + bytes.extend_from_slice(buffer.get(..remaining).unwrap_or_default()); + // Dropping the reader closes the pipe; the supervisor kills the group. + let _ = overflow.send(stream); + return Ok(Drained { + bytes, + overflowed: true, + }); + } + bytes.extend_from_slice(buffer.get(..count).unwrap_or_default()); + } + }) +} diff --git a/crates/trusted-server-release/src/process/redact.rs b/crates/trusted-server-release/src/process/redact.rs new file mode 100644 index 0000000000..c14b3fe07c --- /dev/null +++ b/crates/trusted-server-release/src/process/redact.rs @@ -0,0 +1,63 @@ +//! Secret redaction for diagnostics. + +/// Replacement text for secret values in diagnostics. +pub(super) const REDACTED: &[u8] = b"[REDACTED]"; + +/// Replaces the union of every secret occurrence with one marker per span. +/// +/// Matches are found on the original bytes and merged before replacement, so +/// overlapping or adjacent secrets cannot leave fragments behind. +pub(super) fn redact(bytes: &[u8], secrets: &[Vec]) -> Vec { + let mut ranges: Vec<(usize, usize)> = secrets + .iter() + .filter(|secret| !secret.is_empty()) + .flat_map(|secret| { + bytes + .windows(secret.len()) + .enumerate() + .filter(move |(_, window)| window == secret) + .map(move |(start, _)| (start, start + secret.len())) + }) + .collect(); + ranges.sort_unstable(); + let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len()); + for (start, end) in ranges { + match merged.last_mut() { + Some(last) if start <= last.1 => last.1 = last.1.max(end), + _ => merged.push((start, end)), + } + } + let mut output = Vec::with_capacity(bytes.len()); + let mut cursor = 0; + for (start, end) in merged { + output.extend_from_slice(bytes.get(cursor..start).unwrap_or_default()); + output.extend_from_slice(REDACTED); + cursor = end; + } + output.extend_from_slice(bytes.get(cursor..).unwrap_or_default()); + output +} + +/// Replaces a trailing proper prefix of any secret, as left by a byte cap +/// that cut the stream inside the secret. +pub(super) fn redact_cut_tail(mut bytes: Vec, secrets: &[Vec]) -> Vec { + let cut = secrets + .iter() + .flat_map(|secret| { + (1..secret.len()).filter(|&length| { + secret + .get(..length) + .is_some_and(|prefix| bytes.ends_with(prefix)) + }) + }) + .max(); + if let Some(length) = cut { + bytes.truncate(bytes.len() - length); + bytes.extend_from_slice(REDACTED); + } + bytes +} + +pub(super) fn redacted_text(bytes: &[u8], secrets: &[Vec]) -> String { + String::from_utf8_lossy(&redact(bytes, secrets)).into_owned() +} diff --git a/crates/trusted-server-release/src/process/supervise.rs b/crates/trusted-server-release/src/process/supervise.rs new file mode 100644 index 0000000000..1efb4fd29f --- /dev/null +++ b/crates/trusted-server-release/src/process/supervise.rs @@ -0,0 +1,334 @@ +//! Leader observation, process-group kill and bounded reaping. + +use std::io; +use std::process::{Child, ExitStatus}; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant}; + +use error_stack::Report; + +use super::{Cancellation, ProcessFailure, ProcessOutcome, Stream, process_error}; +use crate::error::ReleaseError; + +/// Bounded wait for reaping, group disappearance and pipe closure after a kill. +/// +/// This is a supervisor liveness bound, not a release policy value: a killed +/// group closes its pipes promptly, so anything still holding them after this +/// grace has escaped the group. +pub(super) const KILL_GRACE: Duration = Duration::from_secs(2); +/// Supervisor polling interval for leader status, deadlines and cancellation. +pub(super) const POLL_INTERVAL: Duration = Duration::from_millis(10); + +/// Stop conditions observed while the leader runs. +pub(super) struct Watch<'a> { + pub(super) overflow: &'a Receiver, + pub(super) deadline: Instant, + pub(super) cancellation: Option<&'a Cancellation>, +} + +/// Returns whether the leader has exited, leaving it unreaped. +/// +/// `WNOWAIT` keeps the zombie, so the process-group ID stays pinned until +/// [`settle`] has signalled the group. +fn leader_exited(pid: libc::pid_t) -> io::Result { + let id = libc::id_t::try_from(pid) + .ok() + .filter(|_| pid > 1) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "unusable child pid"))?; + // SAFETY: `siginfo_t` is plain integer and pointer data; all-zero is a + // valid value and lets `si_pid == 0` signal "no state change". + let mut info: libc::siginfo_t = unsafe { core::mem::zeroed() }; + // SAFETY: `info` is a valid, writable `siginfo_t` for the call's duration. + let result = unsafe { + libc::waitid( + libc::P_PID, + id, + &raw mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result != 0 { + let source = io::Error::last_os_error(); + if source.kind() == io::ErrorKind::Interrupted { + return Ok(false); + } + return Err(source); + } + // SAFETY: `waitid` filled `info` (or left it zeroed under `WNOHANG`). + Ok(unsafe { info.si_pid() } != 0) +} + +/// Polls until the leader exits (`None`) or a stop condition fires. +fn wait_for_leader(pid: libc::pid_t, watch: &Watch<'_>) -> io::Result> { + loop { + if let Ok(stream) = watch.overflow.try_recv() { + return Ok(Some(ProcessOutcome::OutputLimit { stream })); + } + if leader_exited(pid)? { + return Ok(None); + } + if watch.cancellation.is_some_and(Cancellation::is_cancelled) { + return Ok(Some(ProcessOutcome::Cancelled)); + } + if Instant::now() >= watch.deadline { + return Ok(Some(ProcessOutcome::TimedOut)); + } + match watch.overflow.recv_timeout(POLL_INTERVAL) { + Ok(stream) => return Ok(Some(ProcessOutcome::OutputLimit { stream })), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => thread::sleep(POLL_INTERVAL), + } + } +} + +pub(super) fn signal_group(process_group: libc::pid_t) -> io::Result<()> { + group_signal(process_group, libc::SIGKILL) +} + +fn group_signal(process_group: libc::pid_t, signal: libc::c_int) -> io::Result<()> { + // 0, -1 and 1 would address this supervisor's group, every process or + // init; refuse them without inventing an errno. + if process_group <= 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "refusing to signal a non-child process group", + )); + } + // SAFETY: `killpg` only reads its two integer arguments; it touches no + // memory owned by this process. + let result = unsafe { libc::killpg(process_group, signal) }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +/// Reads the SIGCHLD action without changing it. +fn sigchld_action() -> io::Result { + // SAFETY: `sigaction` is plain integer and mask data; all-zero is a valid + // value for the kernel to overwrite. + let mut current: libc::sigaction = unsafe { core::mem::zeroed() }; + // SAFETY: a null new action only reads the disposition into `current`, + // which is valid and writable for the call's duration. + let result = unsafe { libc::sigaction(libc::SIGCHLD, core::ptr::null(), &raw mut current) }; + if result == 0 { + Ok(current) + } else { + Err(io::Error::last_os_error()) + } +} + +/// Returns whether `action` lets the kernel reap exited children itself. +fn auto_reaps(action: &libc::sigaction) -> bool { + action.sa_sigaction == libc::SIG_IGN || action.sa_flags & libc::SA_NOCLDWAIT != 0 +} + +/// Refuses to spawn while exited children would be reaped automatically. +/// +/// An ignored SIGCHLD survives `exec`, and on Linux it makes the kernel reap +/// the leader at exit, so `waitid` reports `ECHILD` and the group ID is no +/// longer pinned when [`settle`] would signal it. `SA_NOCLDWAIT` does the +/// same but is cleared by `exec`, so only this process can set it. +/// +/// # Errors +/// +/// Returns [`ProcessFailure::UnwaitableChildren`] for an auto-reaping +/// disposition, or [`ProcessFailure::Signal`] when it cannot be read. +pub(super) fn require_waitable_children() -> Result<(), Report> { + let action = sigchld_action().map_err(|source| { + process_error( + ProcessFailure::Signal { + errno: source.raw_os_error(), + }, + format!("cannot read the SIGCHLD disposition: {source}"), + ) + })?; + if auto_reaps(&action) { + return Err(process_error( + ProcessFailure::UnwaitableChildren, + "SIGCHLD is ignored or SA_NOCLDWAIT is set, so exited children cannot be observed", + )); + } + Ok(()) +} + +/// Restores the default SIGCHLD action so spawned children stay waitable. +/// +/// Call this at process start, before any thread or child exists: it changes +/// process-wide signal state inherited from the launching parent. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Process`] when the action cannot be set or the +/// resulting disposition still reaps children automatically. +pub(crate) fn establish_waitable_children() -> Result<(), Report> { + // SAFETY: all-zero is `SIG_DFL` with no flags; the mask is then emptied + // explicitly. + let mut action: libc::sigaction = unsafe { core::mem::zeroed() }; + action.sa_sigaction = libc::SIG_DFL; + // SAFETY: `sa_mask` is a valid, writable signal set owned by `action`. + unsafe { + libc::sigemptyset(&raw mut action.sa_mask); + } + // SAFETY: `action` is a fully initialised action read for the call's + // duration; a null old action is permitted. + let result = + unsafe { libc::sigaction(libc::SIGCHLD, &raw const action, core::ptr::null_mut()) }; + if result != 0 { + let source = io::Error::last_os_error(); + return Err(process_error( + ProcessFailure::Signal { + errno: source.raw_os_error(), + }, + format!("cannot restore the default SIGCHLD disposition: {source}"), + )); + } + require_waitable_children() +} + +fn observation_failure(source: &io::Error) -> Report { + process_error( + ProcessFailure::Signal { + errno: source.raw_os_error(), + }, + format!("cannot observe process group: {source}"), + ) +} + +/// Returns whether `source` may be a transient XNU group-operation refusal. +fn is_transient_refusal(source: &io::Error) -> bool { + source.raw_os_error() == Some(libc::EPERM) +} + +/// Polls for the leader's exit status for at most `grace`; never blocks. +/// +/// Returns `Ok(None)` when the leader is still unreaped after the grace. +pub(super) fn reap_within(child: &mut Child, grace: Duration) -> io::Result> { + let until = Instant::now() + grace; + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if Instant::now() >= until { + return Ok(None); + } + thread::sleep(POLL_INTERVAL); + } +} + +/// Classifies a leader that stayed unreaped after `SIGKILL` and the grace, +/// keeping the real errno of a refused group signal. +pub(super) fn unreaped_failure(refusal: Option<&io::Error>) -> ProcessFailure { + match refusal { + Some(source) => ProcessFailure::Signal { + errno: source.raw_os_error(), + }, + None => ProcessFailure::GroupSurvived, + } +} + +/// Kills the group while the unreaped leader pins its ID, reaps the leader +/// within the grace, then confirms the group is gone. +pub(super) fn settle( + child: &mut Child, + process_group: libc::pid_t, +) -> Result> { + let refusal = signal_group(process_group) + .err() + .filter(|source| source.raw_os_error() != Some(libc::ESRCH)); + // A refused group signal may be XNU's zombie-only EPERM; signal the + // leader directly as well so a live leader still dies. A failure here + // matters only if the leader then stays unreaped, where it is reported. + let direct = refusal.as_ref().and_then(|_| child.kill().err()); + let status = reap_within(child, KILL_GRACE).map_err(|source| { + process_error( + ProcessFailure::Wait { + errno: source.raw_os_error(), + }, + format!("cannot reap child: {source}"), + ) + })?; + let Some(status) = status else { + let message = match &refusal { + Some(source) => format!("child stayed unreaped after killpg failed: {source}"), + None => "child stayed unreaped after SIGKILL".to_owned(), + }; + let report = process_error(unreaped_failure(refusal.as_ref()), message); + return Err(match direct { + Some(source) => report.attach(format!("direct leader kill also failed: {source}")), + None => report, + }); + }; + confirm_group_gone(process_group)?; + Ok(status) +} + +/// Observes group disappearance without delivering another signal. +/// +/// This runs after the leader is reaped, so the group ID is no longer pinned: +/// a recycled ID can cause a bounded false cleanup failure, but signal zero +/// cannot terminate the unrelated group. The initial kill stays in [`settle`]. +fn confirm_group_gone(process_group: libc::pid_t) -> Result<(), Report> { + let until = Instant::now() + KILL_GRACE; + confirm_group_with( + process_group, + group_signal, + || Instant::now() >= until, + || thread::sleep(POLL_INTERVAL), + ) +} + +fn confirm_group_with( + process_group: libc::pid_t, + mut probe: impl FnMut(libc::pid_t, libc::c_int) -> io::Result<()>, + mut expired: impl FnMut() -> bool, + mut pause: impl FnMut(), +) -> Result<(), Report> { + loop { + let expired = expired(); + match probe(process_group, 0) { + Err(source) if source.raw_os_error() == Some(libc::ESRCH) => return Ok(()), + Err(source) if expired || !is_transient_refusal(&source) => { + return Err(observation_failure(&source)); + } + Ok(()) if expired => { + return Err(process_error( + ProcessFailure::GroupSurvived, + format!("process group {process_group} remained observable after cleanup"), + )); + } + Ok(()) | Err(_) => pause(), + } + } +} + +#[cfg(test)] +mod tests; + +/// Waits for the leader's exit or a stop condition. +/// +/// When observation itself fails, the group is still settled before the +/// error is returned, and a cleanup failure is attached to it. +pub(super) fn observe( + child: &mut Child, + process_group: libc::pid_t, + watch: &Watch<'_>, +) -> Result, Report> { + match wait_for_leader(process_group, watch) { + Ok(stop) => Ok(stop), + Err(source) => { + let report = process_error( + ProcessFailure::Wait { + errno: source.raw_os_error(), + }, + format!("cannot observe child: {source}"), + ); + Err(match settle(child, process_group) { + Ok(_) => report, + Err(cleanup) => report.attach(format!("cleanup also failed: {cleanup:?}")), + }) + } + } +} diff --git a/crates/trusted-server-release/src/process/supervise/tests.rs b/crates/trusted-server-release/src/process/supervise/tests.rs new file mode 100644 index 0000000000..eace05d209 --- /dev/null +++ b/crates/trusted-server-release/src/process/supervise/tests.rs @@ -0,0 +1,139 @@ +//! Syscall-sequence checks, plus one real group probe that must never signal. + +use std::io; +use std::os::unix::process::CommandExt as _; + +use super::*; + +#[test] +fn confirmation_never_kills_a_live_group() { + let mut child = std::process::Command::new("/bin/sleep") + .arg("30") + .process_group(0) + .spawn() + .expect("should spawn a live group leader"); + let group = libc::pid_t::try_from(child.id()).expect("should fit pid_t"); + let report = confirm_group_gone(group).expect_err("should report a still-present group"); + let alive = child.try_wait().expect("should poll the leader").is_none(); + let _ = child.kill(); + let _ = child.wait(); + assert_eq!( + report.downcast_ref::(), + Some(&ProcessFailure::GroupSurvived), + "should classify a present group as surviving" + ); + assert!(alive, "should observe the group without signalling it"); +} + +#[test] +fn confirmation_only_observes_after_reaping() { + let mut probes = 0; + let mut pauses = 0; + confirm_group_with( + 700, + |group, signal| { + assert_eq!(group, 700, "should observe the selected group"); + assert_eq!(signal, 0, "should never deliver a signal after reaping"); + probes += 1; + match probes { + 1 => Ok(()), + 2 => Err(io::Error::from_raw_os_error(libc::EPERM)), + _ => Err(io::Error::from_raw_os_error(libc::ESRCH)), + } + }, + || false, + || pauses += 1, + ) + .expect("should confirm disappearance after bounded observations"); + assert_eq!(probes, 3, "should observe each sequence state once"); + assert_eq!(pauses, 2, "should wait only while absence is unconfirmed"); +} + +#[test] +fn confirmation_accepts_immediate_absence_at_the_bound() { + confirm_group_with( + 700, + |_, signal| { + assert_eq!(signal, 0, "should use a read-only probe"); + Err(io::Error::from_raw_os_error(libc::ESRCH)) + }, + || true, + || panic!("should not wait after observing absence"), + ) + .expect("should accept confirmed absence even at the deadline"); +} + +#[test] +fn confirmation_bounds_persistent_presence_and_permission_refusal() { + for (errno, expected) in [ + (None, ProcessFailure::GroupSurvived), + ( + Some(libc::EPERM), + ProcessFailure::Signal { + errno: Some(libc::EPERM), + }, + ), + ] { + let mut probes = 0; + let mut ticks = 0; + let mut pauses = 0; + let report = confirm_group_with( + 700, + |_, signal| { + assert_eq!( + signal, 0, + "should never signal a potentially recycled group" + ); + probes += 1; + errno.map_or(Ok(()), |errno| Err(io::Error::from_raw_os_error(errno))) + }, + || { + ticks += 1; + ticks == 3 + }, + || pauses += 1, + ) + .expect_err("should fail once the observation grace expires"); + assert_eq!( + report.downcast_ref::(), + Some(&expected), + "should preserve the real failure classification" + ); + assert_eq!(probes, 3, "should stop at the supplied bound"); + assert_eq!(pauses, 2, "should not wait after the deadline"); + } +} + +#[test] +fn confirmation_rejects_other_probe_errors_without_retry() { + let report = confirm_group_with( + 700, + |_, signal| { + assert_eq!(signal, 0, "should use a read-only probe"); + Err(io::Error::from_raw_os_error(libc::EINVAL)) + }, + || false, + || panic!("should not retry a definitive observation error"), + ) + .expect_err("should preserve a definitive probe failure"); + assert_eq!( + report.downcast_ref::(), + Some(&ProcessFailure::Signal { + errno: Some(libc::EINVAL) + }), + "should preserve the observed errno" + ); +} + +#[test] +fn group_probe_refuses_unowned_ids_before_the_syscall() { + for group in [-1, 0, 1] { + let error = + group_signal(group, 0).expect_err("should refuse an unsafe group before observing it"); + assert_eq!( + error.kind(), + io::ErrorKind::InvalidInput, + "should reject the group without a system call" + ); + } +} diff --git a/crates/trusted-server-release/src/process/tests.rs b/crates/trusted-server-release/src/process/tests.rs new file mode 100644 index 0000000000..94b7a227c9 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests.rs @@ -0,0 +1,57 @@ +//! Process supervision tests. +//! +//! Fixtures are host-independent: instead of ambient shells or coreutils, +//! each child re-executes this test binary in a hidden helper mode selected +//! by [`HELPER_SELECTOR`] (see [`helper_entry`] and [`fixture`]). The helper +//! implements every process shape with std and libc only. +//! +//! The helper fails closed: a selector without the exact helper filter and a +//! matching per-run nonce fails the test run instead of acting or skipping. +//! +//! Genuine `EPERM` group-signal refusals and a group that survives +//! `SIGKILL` need privileges or kernel states a test cannot arrange, so those +//! paths are covered only by unit tests of [`reap_within`] and +//! [`unreaped_failure`], not end to end. + +mod fixture; +mod outcomes; +mod redaction; +mod supervision; +mod variables; + +use std::io; +use std::process::Command as StdCommand; +use std::thread; + +use self::fixture::{ + CUT_KEPT, CUT_PADDING, CUT_SECRET, HELPER_NONCE, HELPER_PIDS, HELPER_SELECTOR, + HELPER_SETUP_FAILED, HELPER_TEST, LARGE_STDERR, OVERLAP_FIRST, OVERLAP_SECOND, SECRET, + SIGCHLD_NOT_REFUSED, SIGCHLD_NOT_RESTORED, SIGCHLD_RESTORED_RUN_FAILED, SLACK, TIMEOUT_WALL, + authorized_mode, failure, force_kill, harness_arguments, helper, helper_selector, is_alive, + is_dead_soon, limits, payload, read_pid, run_helper, supervision_bound, test_binary, + unauthorized_helper_run, workspace, +}; +use super::supervise::{signal_group, unreaped_failure}; +use super::*; + +/// Hidden re-exec entry point; returns immediately in normal test runs. +/// +/// A selector present without the fixture's exact filter and nonce fails the +/// run rather than silently acting or passing. +#[test] +fn helper_entry() { + let Some(selector) = std::env::var_os(HELPER_SELECTOR) else { + return; + }; + let nonce = std::env::var(HELPER_NONCE).ok(); + let arguments: Vec = std::env::args().collect(); + let mode = authorized_mode(&selector.to_string_lossy(), nonce.as_deref(), &arguments); + assert!( + mode.is_some(), + "should run helper mode only from a fixture-launched exact helper invocation" + ); + let code = mode.map_or(HELPER_SETUP_FAILED, |mode| run_helper(&mode)); + // SAFETY: `_exit` ends the helper without returning to the harness, so no + // harness summary is appended to the captured output. + unsafe { libc::_exit(code) } +} diff --git a/crates/trusted-server-release/src/process/tests/fixture.rs b/crates/trusted-server-release/src/process/tests/fixture.rs new file mode 100644 index 0000000000..2e9ca48b40 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/fixture.rs @@ -0,0 +1,558 @@ +//! Host-independent child fixtures and test-side utilities. +//! +//! Helper modes run inside a re-executed test binary (see +//! [`super::helper_entry`]) using std and libc only. Forked helper children +//! call only async-signal-safe functions. + +use std::io::Read as _; +use std::process::Command as StdCommand; +use std::sync::atomic::AtomicU64; +use std::thread; + +use tempfile::TempDir; + +use super::*; + +/// Environment selector `:`; absent in normal test runs. +pub(super) const HELPER_SELECTOR: &str = "TRUSTED_SERVER__RELEASE_PROCESS_HELPER"; +/// Environment variable carrying the nonce the selector must match. +pub(super) const HELPER_NONCE: &str = "TRUSTED_SERVER__RELEASE_PROCESS_NONCE"; +/// Exact libtest name of [`super::helper_entry`]. +pub(super) const HELPER_TEST: &str = "process::tests::helper_entry"; +/// Exit status of a helper whose fork, pipe or pid-file setup failed. +pub(super) const HELPER_SETUP_FAILED: i32 = 90; +/// Exit status of an unknown helper mode. +const HELPER_UNKNOWN_MODE: i32 = 97; +/// Environment variable naming the directory where helpers record pids. +pub(super) const HELPER_PIDS: &str = "TRUSTED_SERVER__RELEASE_PROCESS_PIDS"; +/// How long sleeping helpers wait; every supervision bound must stay below it. +pub(super) const HELPER_SLEEP: Duration = Duration::from_secs(30); +/// Marks the start of helper stdout after the test harness preamble. +pub(super) const SENTINEL: &[u8] = b"\x1eTS-PROCESS-HELPER\x1e"; +pub(super) const SECRET: &str = "fixture-secret-value-0451"; +pub(super) const CUT_SECRET: &str = "Qz7-secret-Wq9"; +pub(super) const CUT_PADDING: usize = 100; +pub(super) const CUT_KEPT: usize = 6; +pub(super) const OVERLAP_FIRST: &str = "Kq1Vv8Mm"; +pub(super) const OVERLAP_SECOND: &str = "Vv8MmJx3"; +const OVERLAP_TEXT: &str = "Kq1Vv8MmJx3"; +/// Stderr written before any stdout; far above a pipe buffer. +pub(super) const LARGE_STDERR: usize = 1024 * 1024 + 17; +/// Headroom for harness startup and scheduling in wall-time assertions. +pub(super) const SLACK: Duration = Duration::from_secs(10); +/// Wall deadline for timeout tests; long enough for helpers to record pids. +pub(super) const TIMEOUT_WALL: Duration = Duration::from_secs(2); + +// --------------------------------------------------------------------------- +// Helper authorization and modes (run inside the re-executed child) +// --------------------------------------------------------------------------- + +/// Returns the helper mode when the selector, nonce and argv all match a +/// fixture-launched helper run. +pub(super) fn authorized_mode( + selector: &str, + nonce: Option<&str>, + arguments: &[String], +) -> Option { + let (mode, selector_nonce) = selector.rsplit_once(':')?; + let exact = arguments.iter().any(|argument| argument == "--exact"); + let filtered = arguments.iter().any(|argument| argument == HELPER_TEST); + let matched = nonce.is_some_and(|nonce| !nonce.is_empty() && nonce == selector_nonce); + (exact && filtered && matched).then(|| mode.to_owned()) +} + +/// Runs helper `mode` and returns its exit status. +pub(super) fn run_helper(mode: &str) -> i32 { + output_mode(mode) + .or_else(|| group_mode(mode)) + .or_else(|| secret_mode(mode)) + .or_else(|| supervisor_mode(mode)) + .unwrap_or(HELPER_UNKNOWN_MODE) +} + +/// Modes that only write fixed output and exit. +fn output_mode(mode: &str) -> Option { + match mode { + "print" => { + write_stream(1, &[SENTINEL, b"out\nline"].concat()); + write_stream(2, b"err text"); + Some(0) + } + "stderr-then-stdout" => { + write_stream(2, &[b'e'; LARGE_STDERR]); + write_stream(1, &[SENTINEL, b"small"].concat()); + Some(0) + } + "bulk-stderr" => { + write_stream(2, &b"abcdefghij".repeat(2000)); + write_stream(2, b"TAIL"); + Some(1) + } + "env" => { + let mut lines: Vec> = std::env::vars_os() + .map(|(name, value)| [name.as_bytes(), b"=", value.as_bytes(), b"\n"].concat()) + .collect(); + lines.sort(); + write_stream(1, &[SENTINEL, &lines.concat()].concat()); + Some(0) + } + "cat" => { + let mut input = Vec::new(); + let _ = std::io::stdin().read_to_end(&mut input); + write_stream(1, &[SENTINEL, &input, b"|done"].concat()); + Some(0) + } + other => other + .strip_prefix("exit:") + .and_then(|code| code.parse().ok()) + .inspect(|_| write_stream(2, b"diagnostic detail")), + } +} + +/// Modes that shape the process group: sleepers, descendants, floods, +/// escapes and signals. Pids are recorded before anything else happens. +fn group_mode(mode: &str) -> Option { + match mode { + "raise" => { + // SAFETY: raising SIGTERM with its default disposition ends the helper. + unsafe { + libc::raise(libc::SIGTERM); + } + Some(0) + } + "sleep" => { + record_pid("leader.pid", own_pid()); + thread::sleep(HELPER_SLEEP); + Some(0) + } + "linger" => { + record_pid("grandchild.pid", fork_paused()); + Some(0) + } + "group-sleep" => { + record_pid("leader.pid", own_pid()); + record_pid("grandchild.pid", fork_paused()); + thread::sleep(HELPER_SLEEP); + Some(0) + } + "flood-both" => { + record_pid("grandchild.pid", fork_flood(2)); + Some(flood(1)) + } + "flood-stderr" => Some(flood(2)), + "escape" => { + record_pid("escaped.pid", fork_escaped(false)); + Some(0) + } + "daemon" => { + record_pid("escaped.pid", fork_escaped(true)); + Some(0) + } + _ => None, + } +} + +/// Exit status when an auto-reaping SIGCHLD disposition is not refused. +pub(super) const SIGCHLD_NOT_REFUSED: i32 = 81; +/// Exit status when restoring the default SIGCHLD action fails. +pub(super) const SIGCHLD_NOT_RESTORED: i32 = 82; +/// Exit status when a spawn after restoring SIGCHLD does not succeed. +pub(super) const SIGCHLD_RESTORED_RUN_FAILED: i32 = 83; + +/// Modes where this isolated helper process acts as the supervisor itself. +/// +/// They change process-wide signal state, so they run only here and never in +/// the shared test process. +fn supervisor_mode(mode: &str) -> Option { + let (handler, flags) = match mode { + "ignored-sigchld" => (libc::SIG_IGN, 0), + "nocldwait-sigchld" => (libc::SIG_DFL, libc::SA_NOCLDWAIT), + _ => return None, + }; + Some({ + // SAFETY: all-zero is a valid action with an empty mask; the handler + // and flags are set below. + let mut action: libc::sigaction = unsafe { core::mem::zeroed() }; + action.sa_sigaction = handler; + action.sa_flags = flags; + // SAFETY: the action changes only this helper process's SIGCHLD + // disposition, the auto-reaping state the mode names; a null old + // action is permitted. + if unsafe { libc::sigaction(libc::SIGCHLD, &raw const action, core::ptr::null_mut()) } != 0 + { + return Some(HELPER_SETUP_FAILED); + } + let nested = workspace(); + let refused = helper(&nested, "print", limits(SLACK)).run(); + if !matches!( + refused.as_ref().err().and_then(failure), + Some(ProcessFailure::UnwaitableChildren) + ) { + return Some(SIGCHLD_NOT_REFUSED); + } + if establish_waitable_children().is_err() { + return Some(SIGCHLD_NOT_RESTORED); + } + match helper(&nested, "print", limits(SLACK)).run() { + Ok(result) if result.outcome() == (ProcessOutcome::Exited { code: 0 }) => 0, + _ => SIGCHLD_RESTORED_RUN_FAILED, + } + }) +} + +/// Modes that echo secret-classified environment values. +fn secret_mode(mode: &str) -> Option { + match mode { + "secret" => { + let secret = read_secret(); + write_stream(2, &[b"leak:", secret.as_slice(), b":"].concat()); + write_stream(1, &secret); + Some(3) + } + "secret-cut" => { + write_stream( + 2, + &[&b"x".repeat(CUT_PADDING), read_secret().as_slice()].concat(), + ); + Some(1) + } + "overlap" => { + write_stream(2, OVERLAP_TEXT.as_bytes()); + Some(1) + } + _ => None, + } +} + +fn read_secret() -> Vec { + std::env::var_os("TOKEN") + .map(|value| value.as_bytes().to_vec()) + .unwrap_or_default() +} + +fn write_stream(fd: libc::c_int, bytes: &[u8]) { + let mut rest = bytes; + while !rest.is_empty() { + // SAFETY: `rest` is a valid initialized buffer of `rest.len()` bytes. + let written = unsafe { libc::write(fd, rest.as_ptr().cast(), rest.len()) }; + let Ok(written) = usize::try_from(written) else { + return; + }; + rest = rest.get(written..).unwrap_or_default(); + } +} + +/// Writes to `fd` until the write fails, then returns exit status zero. +fn flood(fd: libc::c_int) -> i32 { + let chunk = [b'o'; 4096]; + loop { + // SAFETY: `chunk` is a valid initialized buffer. + if unsafe { libc::write(fd, chunk.as_ptr().cast(), chunk.len()) } < 0 { + return 0; + } + } +} + +fn setup_failed() -> ! { + // SAFETY: `_exit` is async-signal-safe and ends the helper immediately. + unsafe { libc::_exit(HELPER_SETUP_FAILED) } +} + +/// Records `pid`; a pid that could address a process group or every process +/// (`<= 1`) or an unwritable pid file ends the helper instead. +fn record_pid(name: &str, pid: libc::pid_t) { + if pid <= 1 { + setup_failed(); + } + let Some(directory) = std::env::var_os(HELPER_PIDS) else { + setup_failed(); + }; + if std::fs::write(Path::new(&directory).join(name), pid.to_string()).is_err() { + setup_failed(); + } +} + +fn own_pid() -> libc::pid_t { + // SAFETY: `getpid` has no preconditions. + unsafe { libc::getpid() } +} + +fn pause_forever() -> ! { + loop { + // SAFETY: `pause` is async-signal-safe and has no preconditions. + unsafe { + libc::pause(); + } + } +} + +/// Forks a same-group child that waits until killed. +fn fork_paused() -> libc::pid_t { + // SAFETY: the forked child only calls the async-signal-safe `pause`. + let pid = unsafe { libc::fork() }; + if pid == 0 { + pause_forever(); + } + if pid < 0 { + setup_failed(); + } + pid +} + +/// Forks a same-group child that floods `fd` until the write fails. +/// +/// Each 4 KiB write is followed by a 1 ms pause so the flood stays far below +/// a 64 MiB cap even when the leader is descheduled under parallel tests. +fn fork_flood(fd: libc::c_int) -> libc::pid_t { + // SAFETY: the forked child only calls async-signal-safe `write`, + // `nanosleep` and `_exit`. + let pid = unsafe { libc::fork() }; + if pid == 0 { + let chunk = [b'e'; 4096]; + let pause = libc::timespec { + tv_sec: 0, + tv_nsec: 1_000_000, + }; + loop { + // SAFETY: `chunk` and `pause` are valid for the calls' duration. + unsafe { + if libc::write(fd, chunk.as_ptr().cast(), chunk.len()) < 0 { + libc::_exit(0); + } + libc::nanosleep(&raw const pause, core::ptr::null_mut()); + } + } + } + if pid < 0 { + setup_failed(); + } + pid +} + +/// Waits for the one-byte readiness signal, retrying `EINTR`. +fn await_ready(read_end: libc::c_int) { + let mut byte = [0_u8; 1]; + loop { + // SAFETY: `read_end` comes from `pipe` and `byte` is writable. + let count = unsafe { libc::read(read_end, byte.as_mut_ptr().cast(), 1) }; + if count == 1 { + return; + } + if count < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { + continue; + } + setup_failed(); + } +} + +/// Forks a child that calls `setsid()` (leaving the process group) and +/// optionally closes its stdio, then reports readiness and waits forever. +fn fork_escaped(close_stdio: bool) -> libc::pid_t { + let mut ready = [0 as libc::c_int; 2]; + // SAFETY: `ready` provides space for the two pipe descriptors. + if unsafe { libc::pipe(ready.as_mut_ptr()) } != 0 { + setup_failed(); + } + let [read_end, write_end] = ready; + // SAFETY: the forked child only calls async-signal-safe functions. + let pid = unsafe { libc::fork() }; + if pid == 0 { + // SAFETY: setsid/close/write are async-signal-safe; descriptors are valid. + unsafe { + libc::setsid(); + if close_stdio { + libc::close(0); + libc::close(1); + libc::close(2); + } + libc::write(write_end, b"r".as_ptr().cast(), 1); + libc::close(write_end); + } + pause_forever(); + } + if pid < 0 { + setup_failed(); + } + // SAFETY: `write_end` comes from `pipe` above and is no longer needed here. + unsafe { + libc::close(write_end); + } + await_ready(read_end); + // SAFETY: `read_end` comes from `pipe` above. + unsafe { + libc::close(read_end); + } + pid +} + +// --------------------------------------------------------------------------- +// Test-side utilities (run in the supervising test) +// --------------------------------------------------------------------------- + +pub(super) fn limits(wall: Duration) -> ProcessLimits { + ProcessLimits { + wall, + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + } +} + +/// Upper bound for a supervised run: its wall deadline, the reap, group and +/// pipe graces, plus [`SLACK`] for harness startup. +pub(super) fn supervision_bound(wall: Duration) -> Duration { + let bound = wall + KILL_GRACE * 3 + SLACK; + assert!( + bound < HELPER_SLEEP, + "should keep supervision bounds below the helper sleep" + ); + bound +} + +pub(super) fn workspace() -> TempDir { + tempfile::tempdir().expect("should create a private test workspace") +} + +pub(super) fn test_binary() -> PathBuf { + std::env::current_exe().expect("should locate the test binary") +} + +pub(super) fn harness_arguments() -> Vec { + ["--exact", HELPER_TEST, "--nocapture", "--test-threads=1"] + .into_iter() + .map(OsString::from) + .collect() +} + +/// Returns a fresh per-launch nonce and the matching `:` selector. +pub(super) fn helper_selector(mode: &str) -> (String, String) { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let sequence = COUNTER.fetch_add(1, Ordering::SeqCst); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let nonce = format!("{}-{sequence}-{nanos}", std::process::id()); + let selector = format!("{mode}:{nonce}"); + (nonce, selector) +} + +/// Builds a bounded re-exec of this test binary in helper `mode`. +pub(super) fn helper( + workspace: &TempDir, + mode: &str, + process_limits: ProcessLimits, +) -> BoundedCommand { + let (nonce, selector) = helper_selector(mode); + BoundedCommand::new(&test_binary(), workspace.path(), process_limits) + .expect("should accept an absolute program") + .arguments(harness_arguments()) + .environment(HELPER_SELECTOR, selector) + .expect("should accept the helper selector") + .environment(HELPER_NONCE, nonce) + .expect("should accept the helper nonce") + .environment(HELPER_PIDS, workspace.path()) + .expect("should accept the helper pid directory") +} + +/// Runs this test binary with a helper selector but `arguments` and `nonce` +/// that a fixture would not use; returns whether the run succeeded. +pub(super) fn unauthorized_helper_run(arguments: &[&str], nonce: Option<&str>) -> bool { + let dir = workspace(); + let mut command = StdCommand::new(test_binary()); + command + .args(arguments) + .env_clear() + .env(HELPER_SELECTOR, "print:fixture-nonce") + .env(HELPER_PIDS, dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(nonce) = nonce { + command.env(HELPER_NONCE, nonce); + } + command + .status() + .expect("should run the test binary") + .success() +} + +/// Returns helper stdout after the harness preamble. +pub(super) fn payload(stdout: &[u8]) -> &[u8] { + stdout + .windows(SENTINEL.len()) + .position(|window| window == SENTINEL) + .and_then(|start| stdout.get(start + SENTINEL.len()..)) + .unwrap_or_default() +} + +/// Refuses pids that would address a process group or every process. +fn assert_single_process(pid: libc::pid_t) { + assert!( + pid > 1, + "should only signal a single fixture pid, got {pid}" + ); +} + +pub(super) fn read_pid(path: &Path) -> libc::pid_t { + let deadline = Instant::now() + SLACK; + loop { + let text = std::fs::read_to_string(path).unwrap_or_default(); + if let Ok(pid) = text.trim().parse::() { + assert_single_process(pid); + return pid; + } + assert!( + Instant::now() < deadline, + "should observe a pid written to {}", + path.display() + ); + thread::sleep(Duration::from_millis(10)); + } +} + +/// Returns whether Linux reports `pid` as a zombie (`Z` in `/proc//stat`). +/// +/// Elsewhere there is no `/proc`, the read fails and this returns `false`. +fn is_zombie(pid: libc::pid_t) -> bool { + std::fs::read_to_string(format!("/proc/{pid}/stat")) + .ok() + .and_then(|stat| { + stat.rfind(')') + .and_then(|end| stat.get(end + 1..)) + .map(|rest| rest.trim_start().starts_with('Z')) + }) + .unwrap_or(false) +} + +/// Returns whether `pid` is a live (non-zombie) process. +pub(super) fn is_alive(pid: libc::pid_t) -> bool { + assert_single_process(pid); + // SAFETY: `kill` with signal 0 only performs a permission and existence check. + let result = unsafe { libc::kill(pid, 0) }; + let exists = result == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH); + exists && !is_zombie(pid) +} + +/// Waits briefly for an already-killed, reparented pid to disappear. +pub(super) fn is_dead_soon(pid: libc::pid_t) -> bool { + assert_single_process(pid); + let deadline = Instant::now() + KILL_GRACE; + while is_alive(pid) { + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(10)); + } + true +} + +pub(super) fn force_kill(pid: libc::pid_t) { + assert_single_process(pid); + // SAFETY: `kill` only reads its integer arguments; `pid` is one fixture process. + unsafe { + libc::kill(pid, libc::SIGKILL); + } +} + +pub(super) fn failure(report: &Report) -> Option<&ProcessFailure> { + report.downcast_ref::() +} diff --git a/crates/trusted-server-release/src/process/tests/outcomes.rs b/crates/trusted-server-release/src/process/tests/outcomes.rs new file mode 100644 index 0000000000..5a38ccba27 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/outcomes.rs @@ -0,0 +1,237 @@ +//! Outcome classification, output capture, stdin and helper authorization. + +use super::*; + +#[test] +fn success_captures_stdout_and_stderr_exactly() { + let dir = workspace(); + + let result = helper(&dir, "print", limits(SLACK)) + .run() + .expect("should run a successful child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should exit zero" + ); + assert_eq!( + payload(result.stdout()), + b"out\nline", + "should capture stdout exactly" + ); + assert_eq!( + result.stderr(), + b"err text", + "should capture stderr exactly" + ); + assert!( + result.elapsed() < SLACK, + "should report a bounded elapsed time" + ); + let stdout = result.require_success().expect("should accept exit zero"); + assert_eq!(payload(&stdout), b"out\nline", "should return stdout"); +} + +#[test] +fn nonzero_exit_reports_code_and_bounded_stderr() { + let dir = workspace(); + + let result = helper(&dir, "exit:7", limits(SLACK)) + .run() + .expect("should run a failing child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 7 }, + "should report the exit code" + ); + let report = result + .require_success() + .expect_err("should reject a nonzero exit"); + let text = format!("{report:?}"); + assert!( + text.contains("diagnostic detail"), + "should include the stderr excerpt: {text}" + ); + assert!( + matches!(failure(&report), Some(ProcessFailure::Unsuccessful)), + "should classify the failure as unsuccessful" + ); +} + +#[test] +fn stderr_excerpt_is_bounded() { + let dir = workspace(); + + let report = helper(&dir, "bulk-stderr", limits(SLACK)) + .run() + .expect("should run a failing child") + .require_success() + .expect_err("should reject a nonzero exit"); + let text = format!("{report:?}"); + + assert!(text.contains("TAIL"), "should keep the stderr tail: {text}"); + assert!( + text.len() < 20_000 / 2, + "should bound the excerpt well below the captured stderr" + ); +} + +#[test] +fn large_stderr_before_stdout_drains_concurrently() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK, + stdout_max: 1024 * 1024, + stderr_max: 4 * 1024 * 1024, + }; + + let result = helper(&dir, "stderr-then-stdout", process_limits) + .run() + .expect("should run a child that fills stderr first"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should exit zero without blocking on a full stderr pipe" + ); + assert_eq!( + result.stderr().len(), + LARGE_STDERR, + "should capture every stderr byte" + ); + assert_eq!(payload(result.stdout()), b"small", "should capture stdout"); +} + +#[test] +fn signaled_child_reports_signal() { + let dir = workspace(); + + let result = helper(&dir, "raise", limits(SLACK)) + .run() + .expect("should run a signaled child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Signaled { + signal: libc::SIGTERM + }, + "should report the terminating signal" + ); + assert!( + result.require_success().is_err(), + "should reject a signaled child" + ); +} + +#[test] +fn nonexistent_program_reports_real_errno() { + let dir = workspace(); + let missing = PathBuf::from("/nonexistent-trusted-server-release/fixture-tool"); + let command = BoundedCommand::new(&missing, dir.path(), limits(SLACK)) + .expect("should accept an absolute program"); + + let report = command.run().expect_err("should fail to spawn"); + + assert!( + matches!( + failure(&report), + Some(ProcessFailure::Spawn { + errno: Some(libc::ENOENT) + }) + ), + "should record ENOENT: {report:?}" + ); + assert!( + format!("{report:?}").contains(&format!("os error {}", libc::ENOENT)), + "should carry the operating system error text" + ); +} + +#[test] +fn bounded_stdin_reaches_child() { + let dir = workspace(); + let command = helper(&dir, "cat", limits(SLACK)).stdin_bytes(b"from stdin".to_vec()); + + let stdout = command + .run() + .expect("should run the cat helper") + .require_success() + .expect("should exit zero"); + + assert_eq!( + payload(&stdout), + b"from stdin|done", + "should deliver stdin bytes" + ); +} + +#[test] +fn default_stdin_is_null() { + let dir = workspace(); + + let stdout = helper(&dir, "cat", limits(SLACK)) + .run() + .expect("should run the cat helper") + .require_success() + .expect("should exit zero"); + + assert_eq!( + payload(&stdout), + b"|done", + "should read EOF from null stdin" + ); +} + +#[test] +fn helper_selector_without_fixture_invocation_fails_the_run() { + assert!( + !unauthorized_helper_run(&[HELPER_TEST, "--test-threads=1"], Some("fixture-nonce")), + "should fail a non-exact filter run that carries a selector" + ); + assert!( + !unauthorized_helper_run( + &["--exact", HELPER_TEST, "--test-threads=1"], + Some("other-nonce") + ), + "should fail an exact run whose nonce does not match" + ); + assert!( + !unauthorized_helper_run(&["--exact", HELPER_TEST, "--test-threads=1"], None), + "should fail an exact run without a nonce" + ); +} + +#[test] +fn helper_authorization_requires_exact_filter_and_nonce() { + let fixture: Vec = ["binary", "--exact", HELPER_TEST] + .into_iter() + .map(str::to_owned) + .collect(); + let loose: Vec = ["binary", HELPER_TEST] + .into_iter() + .map(str::to_owned) + .collect(); + + assert_eq!( + authorized_mode("print:n1", Some("n1"), &fixture), + Some("print".to_owned()), + "should authorize a fixture invocation" + ); + assert_eq!( + authorized_mode("print", Some("n1"), &fixture), + None, + "should reject a selector without a nonce" + ); + assert_eq!( + authorized_mode("print:n1", Some(""), &fixture), + None, + "should reject an empty nonce" + ); + assert_eq!( + authorized_mode("print:n1", Some("n1"), &loose), + None, + "should reject a non-exact filter" + ); +} diff --git a/crates/trusted-server-release/src/process/tests/redaction.rs b/crates/trusted-server-release/src/process/tests/redaction.rs new file mode 100644 index 0000000000..287b516b61 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/redaction.rs @@ -0,0 +1,171 @@ +//! Secret redaction in Debug output and failure excerpts. + +use super::*; + +#[test] +fn secret_values_never_appear_in_debug_or_error_text() { + let dir = workspace(); + let command = helper(&dir, "secret", limits(SLACK)) + .secret_environment("TOKEN", SECRET) + .expect("should accept a secret variable"); + + let command_text = format!("{command:?}"); + let result = command.run().expect("should run the child"); + let result_text = format!("{result:?}"); + let report = result + .require_success() + .expect_err("should reject a nonzero exit"); + let report_text = format!("{report:?}"); + + assert!( + command_text.contains("TOKEN"), + "should still name the secret variable" + ); + for text in [&command_text, &result_text, &report_text] { + assert!(!text.contains(SECRET), "should redact the secret value"); + } + assert!( + report_text.contains("leak:[REDACTED]:"), + "should mark the redacted stderr span: {report_text}" + ); +} + +#[test] +fn secret_cut_at_stderr_cap_is_redacted() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK, + stdout_max: 1024, + stderr_max: u64::try_from(CUT_PADDING + CUT_KEPT).expect("should fit in u64"), + }; + let command = helper(&dir, "secret-cut", process_limits) + .secret_environment("TOKEN", CUT_SECRET) + .expect("should accept a secret variable"); + + let result = command.run().expect("should run the child"); + assert_eq!( + result.outcome(), + ProcessOutcome::OutputLimit { + stream: Stream::Stderr + }, + "should cut stderr at its cap" + ); + let report = result + .require_success() + .expect_err("should reject a capped run"); + let text = format!("{report:?}"); + + let kept = CUT_SECRET + .get(..CUT_KEPT) + .expect("should slice the fixture"); + assert!( + !text.contains(kept), + "should redact a secret prefix cut by the cap: {text}" + ); + assert!(text.contains("[REDACTED]"), "should mark the cut: {text}"); +} + +#[test] +fn cut_tail_redaction_follows_actual_stderr_truncation() { + let secrets = vec![CUT_SECRET.as_bytes().to_vec()]; + let result = |outcome, stderr_truncated| ProcessResult { + program: PathBuf::from("/fixture/tool"), + outcome, + stdout: Vec::new(), + stderr: b"exit 1: Q".to_vec(), + stderr_truncated, + elapsed: Duration::ZERO, + secrets: secrets.clone(), + }; + let text = |result: ProcessResult| { + format!( + "{:?}", + result + .require_success() + .expect_err("should reject an unsuccessful run") + ) + }; + + let complete = text(result(ProcessOutcome::Exited { code: 1 }, false)); + let stderr_capped = text(result( + ProcessOutcome::OutputLimit { + stream: Stream::Stderr, + }, + true, + )); + // Stdout overflowed first, but stderr was also cut mid-secret. + let both_capped = text(result( + ProcessOutcome::OutputLimit { + stream: Stream::Stdout, + }, + true, + )); + let stdout_only = text(result( + ProcessOutcome::OutputLimit { + stream: Stream::Stdout, + }, + false, + )); + + for untouched in [&complete, &stdout_only] { + assert!( + untouched.contains("exit 1: Q") && !untouched.contains("[REDACTED]"), + "should leave untruncated stderr untouched: {untouched}" + ); + } + for redacted in [&stderr_capped, &both_capped] { + assert!( + redacted.contains("exit 1: [REDACTED]"), + "should redact a possible secret prefix at a stderr cut: {redacted}" + ); + } +} + +#[test] +fn overlapping_secrets_are_fully_redacted() { + let dir = workspace(); + let command = helper(&dir, "overlap", limits(SLACK)) + .secret_environment("FIRST", OVERLAP_FIRST) + .expect("should accept a secret variable") + .secret_environment("SECOND", OVERLAP_SECOND) + .expect("should accept a secret variable"); + + let report = command + .run() + .expect("should run the child") + .require_success() + .expect_err("should reject a nonzero exit"); + let text = format!("{report:?}"); + + for fragment in ["Kq1", "Vv8", "Jx3"] { + assert!( + !text.contains(fragment), + "should redact every byte of overlapping secrets: {text}" + ); + } +} + +#[test] +fn redaction_merges_overlapping_ranges() { + let secrets = vec![b"abcXYZ".to_vec(), b"XYZdef".to_vec()]; + + assert_eq!( + redact(b"", &secrets), + b"<[REDACTED]>".to_vec(), + "should replace the merged span once" + ); +} + +#[test] +fn debug_redacts_program_and_working_directory() { + let program = PathBuf::from(format!("/fixture/{SECRET}/tool")); + let working_directory = PathBuf::from(format!("/fixture/{SECRET}")); + let command = BoundedCommand::new(&program, &working_directory, limits(SLACK)) + .expect("should accept absolute paths") + .secret_environment("TOKEN", SECRET) + .expect("should accept a secret variable"); + + let text = format!("{command:?}"); + + assert!(!text.contains(SECRET), "should redact paths"); +} diff --git a/crates/trusted-server-release/src/process/tests/supervision.rs b/crates/trusted-server-release/src/process/tests/supervision.rs new file mode 100644 index 0000000000..93cd665558 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/supervision.rs @@ -0,0 +1,317 @@ +//! Group kill, reaping, deadlines, cancellation, caps and escapes. + +use super::*; + +#[test] +fn timeout_kills_and_reaps_leader() { + let dir = workspace(); + let started = Instant::now(); + + let result = helper(&dir, "sleep", limits(TIMEOUT_WALL)) + .run() + .expect("should supervise a timed-out child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::TimedOut, + "should report a timeout outcome rather than an errno" + ); + assert!( + started.elapsed() < supervision_bound(TIMEOUT_WALL), + "should stop within the wall deadline plus the kill graces" + ); + let leader = read_pid(&dir.path().join("leader.pid")); + assert!(!is_alive(leader), "should reap the leader"); + let report = result + .require_success() + .expect_err("should reject a timeout"); + assert_eq!( + failure(&report), + Some(&ProcessFailure::Unsuccessful), + "should classify a timeout as an errno-free unsuccessful outcome" + ); +} + +#[test] +fn normal_exit_kills_lingering_group_descendant() { + let dir = workspace(); + + let result = helper(&dir, "linger", limits(SLACK)) + .run() + .expect("should run a child with a descendant"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should report the leader exit" + ); + let grandchild = read_pid(&dir.path().join("grandchild.pid")); + assert!( + is_dead_soon(grandchild), + "should kill the same-group grandchild" + ); +} + +#[test] +fn timeout_kills_leader_and_grandchild() { + let dir = workspace(); + + let result = helper(&dir, "group-sleep", limits(TIMEOUT_WALL)) + .run() + .expect("should supervise a timed-out group"); + + assert_eq!( + result.outcome(), + ProcessOutcome::TimedOut, + "should report a timeout" + ); + assert!( + !is_alive(read_pid(&dir.path().join("leader.pid"))), + "should reap the leader" + ); + assert!( + is_dead_soon(read_pid(&dir.path().join("grandchild.pid"))), + "should kill the grandchild" + ); +} + +#[test] +fn stdout_cap_with_stderr_flood_does_not_deadlock() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK * 3, + stdout_max: 64 * 1024, + stderr_max: 64 * 1024 * 1024, + }; + let started = Instant::now(); + + let result = helper(&dir, "flood-both", process_limits) + .run() + .expect("should supervise a flooding child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::OutputLimit { + stream: Stream::Stdout + }, + "should report the stdout cap" + ); + assert!( + result.stdout().len() <= 64 * 1024, + "should keep stdout within its cap" + ); + assert!( + started.elapsed() < supervision_bound(Duration::ZERO), + "should stop at the cap, well before the wall deadline" + ); + assert!( + is_dead_soon(read_pid(&dir.path().join("grandchild.pid"))), + "should kill the flooding grandchild" + ); +} + +#[test] +fn stderr_cap_reports_stderr() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK, + stdout_max: 1024 * 1024, + stderr_max: 4096, + }; + + let result = helper(&dir, "flood-stderr", process_limits) + .run() + .expect("should supervise a flooding child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::OutputLimit { + stream: Stream::Stderr + }, + "should report the stderr cap" + ); + assert!( + result.stderr().len() <= 4096, + "should keep stderr within its cap" + ); +} + +#[test] +fn cancellation_from_another_thread_stops_group() { + let dir = workspace(); + let cancellation = Cancellation::default(); + let command = helper(&dir, "group-sleep", limits(SLACK * 3)).cancellation(cancellation.clone()); + let grandchild_file = dir.path().join("grandchild.pid"); + let canceller = thread::spawn(move || { + let grandchild = read_pid(&grandchild_file); + cancellation.cancel(); + grandchild + }); + let started = Instant::now(); + + let result = command.run().expect("should supervise a cancelled child"); + let grandchild = canceller.join().expect("should join the cancelling thread"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Cancelled, + "should report cancellation" + ); + assert!( + started.elapsed() < supervision_bound(Duration::ZERO), + "should stop promptly after cancellation" + ); + assert!( + is_dead_soon(grandchild), + "should kill the grandchild on cancellation" + ); +} + +#[test] +fn cancellation_before_spawn_starts_nothing() { + let dir = workspace(); + let cancellation = Cancellation::default(); + cancellation.cancel(); + + let result = helper(&dir, "sleep", limits(SLACK)) + .cancellation(cancellation) + .run() + .expect("should report a pre-cancelled run"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Cancelled, + "should report cancellation" + ); + assert!( + !dir.path().join("leader.pid").exists(), + "should not spawn a child after cancellation" + ); +} + +#[test] +fn escaped_descendant_holding_pipes_is_reported() { + let dir = workspace(); + let started = Instant::now(); + + let report = helper(&dir, "escape", limits(SLACK)) + .run() + .expect_err("should reject output held by an escaped descendant"); + let elapsed = started.elapsed(); + let escaped = read_pid(&dir.path().join("escaped.pid")); + force_kill(escaped); + + assert!( + matches!(failure(&report), Some(ProcessFailure::DescendantEscaped)), + "should classify the escape: {report:?}" + ); + assert!( + elapsed < supervision_bound(Duration::ZERO), + "should give up within the bounded drain grace" + ); +} + +/// Documents a residual this module cannot close: a `setsid()` daemon with +/// closed stdio leaves no observable trace for the supervisor. The Task 7 OS +/// boundary (container PID namespace, macOS sandbox) must contain it. +#[test] +fn closed_stdio_daemon_escape_is_not_detected_here() { + let dir = workspace(); + + let result = helper(&dir, "daemon", limits(SLACK)) + .run() + .expect("should not observe a daemon that closed its stdio"); + let daemon = read_pid(&dir.path().join("escaped.pid")); + let survived = is_alive(daemon); + force_kill(daemon); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should report only the leader exit" + ); + assert!( + survived, + "should leave the escaped daemon to the OS boundary (documented residual)" + ); +} + +#[test] +fn reaping_is_bounded_and_never_blocks() { + let dir = workspace(); + let (nonce, selector) = helper_selector("sleep"); + let mut child = StdCommand::new(test_binary()) + .args(harness_arguments()) + .env_clear() + .env(HELPER_SELECTOR, selector) + .env(HELPER_NONCE, nonce) + .env(HELPER_PIDS, dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("should spawn an unsignalled helper"); + let started = Instant::now(); + + let reaped = reap_within(&mut child, Duration::from_millis(200)); + let elapsed = started.elapsed(); + let _ = child.kill(); + let _ = child.wait(); + + assert!( + matches!(reaped, Ok(None)), + "should report an unreaped leader instead of waiting" + ); + assert!( + elapsed < KILL_GRACE, + "should return after the bounded grace" + ); +} + +#[test] +fn unreaped_leader_failure_keeps_real_errno() { + let refused = io::Error::from_raw_os_error(libc::EPERM); + + assert_eq!( + unreaped_failure(Some(&refused)), + ProcessFailure::Signal { + errno: Some(libc::EPERM) + }, + "should carry the real killpg errno" + ); + assert_eq!( + unreaped_failure(None), + ProcessFailure::GroupSurvived, + "should report survival when signalling succeeded" + ); +} + +#[test] +fn group_signal_refuses_init_and_broadcast_ids() { + // Only pgid 1 is probed: 0 and -1 would address this runner or every process. + let refused = signal_group(1).expect_err("should refuse process group 1"); + + assert_eq!( + refused.raw_os_error(), + None, + "should refuse before calling killpg, without a synthesized errno" + ); +} + +#[test] +fn auto_reaping_sigchld_refuses_spawn_until_the_default_is_restored() { + let dir = workspace(); + + for mode in ["ignored-sigchld", "nocldwait-sigchld"] { + let result = helper(&dir, mode, limits(SLACK)) + .run() + .expect("should supervise the isolated supervisor helper"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "{mode}: should refuse spawning under an auto-reaping SIGCHLD \ + ({SIGCHLD_NOT_REFUSED}), restore the default ({SIGCHLD_NOT_RESTORED}) and then \ + supervise normally ({SIGCHLD_RESTORED_RUN_FAILED})" + ); + } +} diff --git a/crates/trusted-server-release/src/process/tests/variables.rs b/crates/trusted-server-release/src/process/tests/variables.rs new file mode 100644 index 0000000000..6b38da4b50 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/variables.rs @@ -0,0 +1,231 @@ +//! Command and environment validation. + +use super::*; + +/// Names that inject code or configuration; rejected even as secrets. +const INJECTION_NAMES: [&str; 54] = [ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "LD_ANYTHING", + "DYLD_INSERT_LIBRARIES", + "dyld_insert_libraries", + "NODE_OPTIONS", + "BASH_ENV", + "ENV", + "GCONV_PATH", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_EXEC_PATH", + "GIT_SSH_COMMAND", + "GIT_SSH", + "npm_config_registry", + "NPM_CONFIG_REGISTRY", + "PYTHONPATH", + "PYTHONSTARTUP", + "PERL5OPT", + "PERL5LIB", + "RUBYOPT", + "RUBYLIB", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_CONFIG", + "GIT_ASKPASS", + "SSH_ASKPASS", + "GIT_PROXY_COMMAND", + "GIT_EXTERNAL_DIFF", + "GIT_EDITOR", + "EDITOR", + "VISUAL", + "GIT_PAGER", + "PAGER", + "GIT_TEMPLATE_DIR", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER", + "CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER", + "RUSTC", + "RUSTDOC", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTFLAGS", + "NODE_PATH", + "JAVA_TOOL_OPTIONS", + "_JAVA_OPTIONS", +]; + +/// Credential names accepted only through `secret_environment`. +const CREDENTIAL_NAMES: [&str; 12] = [ + "GH_TOKEN", + "GITHUB_TOKEN", + "NPM_TOKEN", + "NODE_AUTH_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_RUNTIME_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "FASTLY_API_TOKEN", + "github_token", + "CARGO_REGISTRY_TOKEN", + "CARGO_REGISTRIES_EXAMPLE_TOKEN", + "SIGSTORE_ID_TOKEN", +]; + +#[test] +fn relative_program_is_rejected() { + let dir = workspace(); + + let report = BoundedCommand::new(Path::new("helper"), dir.path(), limits(SLACK)) + .expect_err("should reject a relative program path"); + + assert!( + matches!(failure(&report), Some(ProcessFailure::InvalidCommand)), + "should classify an invalid command" + ); +} + +#[test] +fn relative_working_directory_is_rejected() { + let report = BoundedCommand::new(&test_binary(), Path::new("relative"), limits(SLACK)) + .expect_err("should reject a relative working directory"); + + assert!( + matches!(failure(&report), Some(ProcessFailure::InvalidCommand)), + "should classify an invalid command" + ); +} + +#[test] +fn environment_is_exactly_the_explicit_map() { + let dir = workspace(); + let command = helper(&dir, "env", limits(SLACK)) + .environment("ALPHA", "1") + .expect("should accept a plain variable") + .environment("BETA", "two words") + .expect("should accept a plain variable"); + + let stdout = command + .run() + .expect("should run the env helper") + .require_success() + .expect("should exit zero"); + + let text = String::from_utf8(payload(&stdout).to_vec()).expect("should print UTF-8"); + let names: Vec<&str> = text + .lines() + .filter_map(|line| line.split_once('=').map(|(name, _)| name)) + .collect(); + assert_eq!( + names, + vec!["ALPHA", "BETA", HELPER_SELECTOR, HELPER_NONCE, HELPER_PIDS], + "should pass exactly the explicit environment" + ); + assert!( + text.contains("ALPHA=1\nBETA=two words\n"), + "should pass explicit values: {text}" + ); +} + +#[test] +fn injection_variables_are_rejected() { + let dir = workspace(); + for name in INJECTION_NAMES { + let plain = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "/tmp/fixture.so") + .expect_err("should reject an injection variable"); + assert!( + matches!(failure(&plain), Some(ProcessFailure::InvalidCommand)), + "should classify {name} as an invalid command" + ); + assert!( + !format!("{plain:?}").contains("/tmp/fixture.so"), + "should not echo the rejected value" + ); + let secret = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .secret_environment(name, "/tmp/fixture.so"); + assert!( + secret.is_err(), + "should reject {name} as a secret variable too" + ); + } +} + +#[test] +fn credentials_require_secret_classification() { + let dir = workspace(); + for name in CREDENTIAL_NAMES { + let plain = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "credential"); + assert!( + plain.is_err(), + "should refuse {name} through the plain environment" + ); + let secret = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .secret_environment(name, "credential"); + assert!(secret.is_ok(), "should accept {name} as a secret variable"); + } +} + +#[test] +fn malformed_environment_names_are_rejected() { + let dir = workspace(); + for name in ["", "A=B", "NUL\0NAME"] { + let command = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program"); + assert!( + command.environment(name, "value").is_err(), + "should reject environment name {name:?}" + ); + } +} + +#[test] +fn harmless_names_are_accepted() { + let dir = workspace(); + for name in ["CARGO_TARGET_DIR", "CARGO_HOME", "LANG", "HOME", "TZ"] { + let command = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program"); + assert!( + command.environment(name, "value").is_ok(), + "should accept {name}" + ); + } +} + +#[test] +fn aws_lc_sys_build_switches_are_plain_variables() { + let dir = workspace(); + for name in [ + "AWS_LC_SYS_USE_SYSTEM", + "AWS_LC_SYS_USE_SYSTEM_aarch64_apple_darwin", + ] { + let plain = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "0"); + assert!(plain.is_ok(), "should accept {name} as a plain variable"); + } + for name in [ + "AWS_SECRET_ACCESS_KEY", + "AWS_LC_SYS_OTHER", + "AWS_LC_SYS_USE_SYSTEM_", + "AWS_LC_SYS_USE_SYSTEMX", + "AWS_LC_SYS_USE_SYSTEM_x86-64", + ] { + let credential = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "fictional"); + assert!( + credential.is_err(), + "should still refuse {name} as a plain variable" + ); + } +} diff --git a/crates/trusted-server-release/tests/commands.rs b/crates/trusted-server-release/tests/commands.rs new file mode 100644 index 0000000000..2ecb59da14 --- /dev/null +++ b/crates/trusted-server-release/tests/commands.rs @@ -0,0 +1,893 @@ +use std::fs; +use std::io::Read as _; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::os::unix::process::CommandExt as _; +use std::path::Path; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +use flate2::Crc; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use tempfile::tempdir; + +const RELEASE_BIN: &str = env!("CARGO_BIN_EXE_trusted-server-release"); + +#[test] +fn release_package_is_deterministic_and_inspectable() { + let temporary = tempdir().expect("should create package fixture"); + let root = temporary.path(); + let wasm = b"\0asm\x01\0\0\0"; + let mut packages = Vec::new(); + for (name, mode) in [("first", 0o600), ("second", 0o755)] { + let directory = root.join(name); + fs::create_dir(&directory).expect("should create role directory"); + let input = directory.join("input.wasm"); + fs::write(&input, wasm).expect("should write wasm"); + fs::set_permissions(&input, fs::Permissions::from_mode(mode)) + .expect("should vary source mode"); + let output = directory.join("trusted-server-fastly.tar.gz"); + let result = Command::new(RELEASE_BIN) + .args(["pack-fastly-package", "--wasm"]) + .arg(&input) + .arg("--output") + .arg(&output) + .output() + .expect("should build package"); + assert!( + result.status.success(), + "should build deterministic package: {}", + stderr(&result) + ); + packages.push(fs::read(&output).expect("should read package")); + let manifest = directory.join("fastly.toml"); + fs::write( + &manifest, + b"manifest_version = 3\nname = \"trusted-server-fastly\"\n", + ) + .expect("should write fixed manifest"); + let result = Command::new(RELEASE_BIN) + .arg("inspect-fastly-package") + .arg("--package-dir") + .arg(&directory) + .arg("--manifest") + .arg(&manifest) + .args([ + "--expected-manifest-sha256", + &hex::encode(Sha256::digest( + fs::read(&manifest).expect("should read manifest"), + )), + "--expected-wasm-sha256", + &hex::encode(Sha256::digest(wasm)), + ]) + .output() + .expect("should inspect package"); + assert!( + result.status.success(), + "should accept generated package: {}", + stderr(&result) + ); + let again = Command::new(RELEASE_BIN) + .args(["pack-fastly-package", "--wasm"]) + .arg(&input) + .arg("--output") + .arg(&output) + .output() + .expect("should attempt duplicate output"); + assert!( + !again.status.success(), + "should refuse overwriting a package" + ); + } + assert_eq!( + packages[0], packages[1], + "should normalize all packaging metadata" + ); + assert_eq!(&packages[0][4..8], &[0; 4], "should omit gzip timestamp"); +} + +#[test] +fn deferred_producer_commands_are_not_registered() { + for command in [ + "preflight", + "audit-inputs", + "boundary-probe", + "gnu-image-measurement", + ] { + let output = Command::new(RELEASE_BIN) + .arg(command) + .output() + .expect("should invoke release tool"); + assert!( + !output.status.success(), + "should refuse deferred command {command}" + ); + assert!( + String::from_utf8_lossy(&output.stderr).starts_with("Invalid release tool invocation"), + "should leave {command} unregistered: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +fn manual_package_fixture(root: &Path) -> (String, String) { + let manifest = b"manifest_version = 3\nname = \"example-app\"\n"; + let wasm = b"\0asm\x01\0\0\0"; + let directory = root.join("pkg"); + fs::create_dir(&directory).expect("should create package directory"); + fs::write(root.join("fastly.toml"), manifest).expect("should write reviewed manifest"); + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + let mut archive = tar::Builder::new(encoder); + for (path, kind, bytes) in [ + ("example-app/", tar::EntryType::Directory, &b""[..]), + ("example-app/bin/", tar::EntryType::Directory, &b""[..]), + ( + "example-app/bin/main.wasm", + tar::EntryType::Regular, + &wasm[..], + ), + ( + "example-app/fastly.toml", + tar::EntryType::Regular, + &manifest[..], + ), + ] { + let mut header = tar::Header::new_ustar(); + header.set_entry_type(kind); + header.set_mode(0o755); + header.set_size(bytes.len() as u64); + header.set_cksum(); + archive + .append_data(&mut header, path, bytes) + .expect("should write bounded fictional package member"); + } + let bytes = archive + .into_inner() + .expect("should finish archive") + .finish() + .expect("should finish gzip"); + fs::write(directory.join("example-app.tar.gz"), bytes).expect("should write package"); + ( + hex::encode(Sha256::digest(manifest)), + hex::encode(Sha256::digest(wasm)), + ) +} + +fn inspect_manual_package(root: &Path, manifest: &str, wasm: &str) -> Output { + Command::new(RELEASE_BIN) + .current_dir(root) + .env_clear() + .args([ + "inspect-fastly-package", + "--package-dir", + "pkg", + "--manifest", + "fastly.toml", + "--expected-manifest-sha256", + manifest, + "--expected-wasm-sha256", + wasm, + ]) + .output() + .expect("should run only the local release inspector") +} + +#[test] +fn manual_fastly_package_command_accepts_exact_bytes_without_release_authority() { + let root = tempdir().expect("should create private package fixture"); + let (manifest, wasm) = manual_package_fixture(root.path()); + let output = inspect_manual_package(root.path(), &manifest, &wasm); + assert!( + output.status.success(), + "should accept exact package: {}", + stderr(&output) + ); + let report: Value = + serde_json::from_slice(&output.stdout).expect("should parse inspection report"); + assert_eq!( + report["accepted"], true, + "should report the bounded byte checks" + ); + assert_eq!( + report["module_sha256"], wasm, + "should bind the actual module member" + ); + assert_eq!( + report["manifest_sha256"], manifest, + "should bind reviewed manifest bytes" + ); + assert_eq!( + report["limits"]["runtime_validated"], false, + "should retain runtime limitation" + ); + assert_eq!( + report["limits"]["official_release_verified"], false, + "should not mint release trust" + ); + assert_eq!( + report["limits"]["deployment_authorized"], false, + "should not authorize mutation" + ); +} + +#[test] +fn manual_fastly_package_command_rejects_an_unmatched_expected_digest() { + let root = tempdir().expect("should create private package fixture"); + let (manifest, _) = manual_package_fixture(root.path()); + let output = inspect_manual_package(root.path(), &manifest, &"b".repeat(64)); + assert!( + !output.status.success(), + "should reject another expected executable" + ); + assert!( + output.stdout.is_empty(), + "should emit no acceptance on failure" + ); +} + +fn write(root: &Path, path: &str, bytes: &[u8]) { + let path = root.join(path); + fs::create_dir_all(path.parent().expect("should have parent")).expect("should create parent"); + fs::write(path, bytes).expect("should write fixture"); +} +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn ordinary_measurement(root: &Path, node_body: &str) -> Output { + ordinary_measurement_with(root, node_body, |_, _| {}) +} + +/// Runs the measurement after `adjust` may change the tools or the request. +fn ordinary_measurement_with( + root: &Path, + node_body: &str, + adjust: impl FnOnce(&Path, &mut Value), +) -> Output { + ordinary_measurement_command(root, node_body, adjust) + .output() + .expect("should execute fixed measurement command only in CI") +} + +/// Builds the fixture and returns the unlaunched measurement command. +fn ordinary_measurement_command( + root: &Path, + node_body: &str, + adjust: impl FnOnce(&Path, &mut Value), +) -> Command { + let checkout = root.join("workspace/ordinary-canonical-source"); + let target = root.join("temp/ordinary-fastly-canonical-target"); + let cargo_home = root.join("temp/ordinary-canonical-cargo-home"); + fs::create_dir_all(&checkout).expect("should create ordinary source root"); + fs::create_dir_all(&target).expect("should create ordinary target root"); + fs::create_dir_all(&cargo_home).expect("should create the private Cargo home"); + for name in [ + "scripts/release/phase1-build.sh", + "scripts/release/fastly.toml", + ".cargo/config.toml", + "Cargo.toml", + "Cargo.lock", + "crates/trusted-server-js/lib/package-lock.json", + ] { + write(&checkout, name, b"fictional source input\n"); + } + write(&checkout, ".cargo/config.toml", b"[alias]\n"); + let arguments = Command::new(RELEASE_BIN) + .env_clear() + .current_dir(root) + .env("CARGO_HOME", &cargo_home) + .arg("ordinary-rustflags") + .arg("--checkout") + .arg(&checkout) + .arg("--target") + .arg(&target) + .arg("--role") + .arg("canonical") + .arg("--output") + .arg(root.join("arguments")) + .output() + .expect("should prepare fixed arguments only in CI"); + assert!( + arguments.status.success(), + "should seal actual arguments before building the fixture: {}", + String::from_utf8_lossy(&arguments.stderr) + ); + write( + &checkout, + "rust-toolchain.toml", + b"[toolchain]\nchannel = \"1.98.1\"\n", + ); + write(&checkout, ".tool-versions", b"nodejs 24.20.0\n"); + write( + &checkout, + "crates/trusted-server-js/Cargo.toml", + b"fictional JS package\n", + ); + write( + &checkout, + "crates/trusted-server-js/lib/src/core/index.ts", + b"export {};\n", + ); + write( + &checkout, + "release-assets/trusted-server-fastly.wasm", + b"\0asm\x01\0\0\0\0\x0d\x0b.debug_info\0\0\x1b\x0a.debug_str/trusted-server\0\0\x0d\x0b.debug_line\0\0\x06\x04name\0", + ); + let js = checkout.join("crates/trusted-server-js"); + fs::create_dir_all(js.join("lib/src/integrations")) + .expect("should retain an empty integration module set"); + let out = target.join("wasm32-wasip1/release/build/trusted-server-js-fixture/out"); + fs::create_dir_all(out.join("tsjs-dist")).expect("should create consumed private output"); + fs::write( + out.join("tsjs-dist/tsjs-core.js"), + b"fictional private bundle", + ) + .expect("should retain consumed bundle"); + let package = format!("path+file://{}#0.1.0", js.display()); + fs::write( + target.join("metadata.json"), + serde_json::to_vec(&json!({ + "workspace_root":checkout,"workspace_members":[package],"packages":[{ + "id":package,"name":"trusted-server-js","manifest_path":js.join("Cargo.toml") + }] + })) + .expect("should encode metadata"), + ) + .expect("should retain locked metadata fixture"); + let messages = [ + json!({"reason":"build-script-executed","package_id":package,"out_dir":out,"env":[]}), + json!({"reason":"build-finished","success":true}), + ]; + let lines = messages + .iter() + .map(|value| serde_json::to_string(value).expect("should encode Cargo line")) + .collect::>() + .join("\n") + + "\n"; + fs::write(target.join("messages.jsonl"), lines).expect("should retain completed Cargo output"); + for (name, body) in [ + ("rustc", "printf 'rustc 1.98.1 (abcdef123 2026-01-01)\\n'"), + ("node", node_body), + ] { + let script = format!( + "#!/bin/sh\nset -eu\ntest \"$#\" -eq 1\ntest \"$1\" = --version\ntest -z \"${{GH_TOKEN+x}}\"\ntest \"$LC_ALL/$TZ\" = C/UTC\n{body}\n" + ); + write(&checkout, &format!("tools/{name}"), script.as_bytes()); + fs::set_permissions( + checkout.join("tools").join(name), + fs::Permissions::from_mode(0o755), + ) + .expect("should set fixture executable permissions"); + } + write( + &checkout, + "release-assets/trusted-server-fastly.tar.gz", + b"package", + ); + let mut request = json!({ + "role":"canonical","source_commit":"a".repeat(40), + "environment":{ + "checkout_root":checkout,"target_root":target,"cargo_home":cargo_home,"started_at_seconds":1700000000_u64, + "umask":18,"jobs":4,"locale":"C","timezone":"UTC", + "uid":fs::metadata(&checkout).expect("should inspect actual fixture owner").uid() + }, + "rustc":checkout.join("tools/rustc"),"node":checkout.join("tools/node"), + "cargo_messages":target.join("messages.jsonl"),"cargo_metadata":target.join("metadata.json"), + "rustflags":root.join("arguments/rustflags.json") + }); + adjust(&checkout, &mut request); + let request_path = root.join("request.json"); + fs::write( + &request_path, + serde_json::to_vec(&request).expect("should encode capture"), + ) + .expect("should retain capture"); + let mut command = Command::new(RELEASE_BIN); + command + .current_dir(root) + .env_clear() + .env("GH_TOKEN", "fictional-credential-canary") + .arg("ordinary-measure") + .arg("--request") + .arg(request_path) + .arg("--output") + .arg(root.join("observed")); + command +} + +#[test] +fn ordinary_measurement_command_queries_only_versions_without_inherited_secrets() { + let temporary = tempdir().expect("should create process fixture"); + let root = fs::canonicalize(temporary.path()).expect("should resolve process fixture"); + let result = ordinary_measurement(&root, "printf 'v24.20.0\\n'"); + assert!( + result.status.success(), + "should retain actual fixed-query measurements: {}", + stderr(&result) + ); + let observed: Value = serde_json::from_slice( + &fs::read(root.join("observed/fastly-observation.json")) + .expect("should retain unpublished observation"), + ) + .expect("should parse real observation"); + assert_eq!( + observed["tools"]["node"]["version"], "v24.20.0", + "should measure child version output" + ); + assert_eq!( + observed["release_qualification"], false, + "should not qualify ordinary builds" + ); + assert!( + !root + .join("observed/trusted-server-fastly.wasm.rebuild-check.json") + .exists(), + "should not manufacture equality from one build" + ); +} + +#[test] +fn ordinary_measurement_command_waits_for_children_despite_inherited_ignored_sigchld() { + let temporary = tempdir().expect("should create process fixture"); + let root = fs::canonicalize(temporary.path()).expect("should resolve process fixture"); + let mut command = ordinary_measurement_command(&root, "printf 'v24.20.0\\n'", |_, _| {}); + // SAFETY: the closure runs in the forked child before exec and only calls + // `signal`, which is async-signal-safe. An ignored SIGCHLD survives exec, + // so the producer starts the way an ignoring parent would launch it. + unsafe { + command.pre_exec(|| { + if libc::signal(libc::SIGCHLD, libc::SIG_IGN) == libc::SIG_ERR { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let result = command + .output() + .expect("should execute fixed measurement command only in CI"); + assert!( + result.status.success(), + "should keep tool-query children waitable under an inherited ignored SIGCHLD: {}", + stderr(&result) + ); + let observed: Value = serde_json::from_slice( + &fs::read(root.join("observed/fastly-observation.json")) + .expect("should retain unpublished observation"), + ) + .expect("should parse real observation"); + assert_eq!( + observed["tools"]["node"]["version"], "v24.20.0", + "should measure child version output under an inherited ignored SIGCHLD" + ); +} + +#[test] +fn ordinary_measurement_command_rejects_wrong_versions_and_sanitizes_failed_queries() { + for body in [ + "printf 'v24.12.0\\n'", + "printf 'fictional-query-secret\\n' >&2; exit 1", + ] { + let temporary = tempdir().expect("should create failing process fixture"); + let root = fs::canonicalize(temporary.path()).expect("should resolve process fixture"); + let result = ordinary_measurement(&root, body); + assert!( + !result.status.success(), + "should fail on wrong version or failed query" + ); + assert!( + result.stdout.is_empty(), + "should emit no acceptance on failure" + ); + assert!( + !stderr(&result).contains("fictional-query-secret"), + "should not leak captured child diagnostics" + ); + assert!( + !root.join("observed").exists(), + "should retain no output after failed measurement" + ); + } +} + +#[test] +fn ordinary_measurement_command_rejects_unsafe_tool_selection_and_claimed_owner() { + type Adjust = fn(&Path, &mut Value); + let cases: [(&str, &str, Adjust, &str); 6] = [ + ( + "wrapper name", + "printf 'v24.20.0\\n'", + |checkout, request| { + let renamed = checkout.join("tools/node-wrapper"); + fs::rename(checkout.join("tools/node"), &renamed).expect("should rename node"); + request["node"] = json!(renamed); + }, + "not a shim", + ), + ( + "symlinked shim", + "printf 'v24.20.0\\n'", + |checkout, request| { + let shim = checkout.join("tools/shim"); + fs::create_dir(&shim).expect("should create shim directory"); + std::os::unix::fs::symlink(checkout.join("tools/node"), shim.join("node")) + .expect("should link shim"); + request["node"] = json!(shim.join("node")); + }, + "not a shim", + ), + ( + "non-executable mode", + "printf 'v24.20.0\\n'", + |checkout, _| { + fs::set_permissions( + checkout.join("tools/node"), + fs::Permissions::from_mode(0o644), + ) + .expect("should clear executable bits"); + }, + "privileged or not executable", + ), + ( + "setuid mode", + "printf 'v24.20.0\\n'", + |checkout, _| { + fs::set_permissions( + checkout.join("tools/node"), + fs::Permissions::from_mode(0o4755), + ) + .expect("should set the setuid bit"); + }, + "privileged or not executable", + ), + ( + "self-modifying tool", + "printf 'v24.20.0\\n'; printf '#\\n' >> \"$0\"", + |_, _| {}, + "changed while querying", + ), + ( + "claimed owner", + "printf 'v24.20.0\\n'", + |_, request| { + let uid = request["environment"]["uid"] + .as_u64() + .expect("should record the fixture owner"); + request["environment"]["uid"] = json!(uid + 1); + }, + "source, role or environment is invalid", + ), + ]; + for (label, body, adjust, reason) in cases { + let temporary = tempdir().expect("should create unsafe tool fixture"); + let root = fs::canonicalize(temporary.path()).expect("should resolve process fixture"); + let result = ordinary_measurement_with(&root, body, adjust); + assert!( + !result.status.success(), + "should reject the {label} before recording an observation" + ); + assert!( + stderr(&result).contains(reason), + "should report `{reason}` for the {label}, got: {}", + stderr(&result) + ); + assert!( + !root.join("observed").exists(), + "should retain no output for the {label}" + ); + } +} + +// The fixture uses the same narrow stored-ZIP construction as the ordinary +// archive unit tests; it invokes no archive tools or producer qualification. +fn ordinary_fixture_zip(members: &[(&str, &[u8])]) -> Vec { + let mut bytes = Vec::new(); + let mut central = Vec::new(); + for (name, body) in members { + let offset = bytes.len() as u32; + let mut crc = Crc::new(); + crc.update(body); + let crc = crc.sum(); + let size = body.len() as u32; + bytes.extend(0x04034b50_u32.to_le_bytes()); + bytes.extend(20_u16.to_le_bytes()); + bytes.extend([0; 8]); + for field in [crc, size, size] { + bytes.extend(field.to_le_bytes()); + } + bytes.extend((name.len() as u16).to_le_bytes()); + bytes.extend([0; 2]); + bytes.extend(name.as_bytes()); + bytes.extend(*body); + central.extend(0x02014b50_u32.to_le_bytes()); + central.extend(20_u16.to_le_bytes()); + central.extend(20_u16.to_le_bytes()); + central.extend([0; 8]); + for field in [crc, size, size] { + central.extend(field.to_le_bytes()); + } + central.extend((name.len() as u16).to_le_bytes()); + central.extend([0; 12]); + central.extend(offset.to_le_bytes()); + central.extend(name.as_bytes()); + } + let offset = bytes.len() as u32; + let size = central.len() as u32; + bytes.extend(central); + bytes.extend(0x06054b50_u32.to_le_bytes()); + bytes.extend([0; 4]); + for _ in 0..2 { + bytes.extend((members.len() as u16).to_le_bytes()); + } + bytes.extend(size.to_le_bytes()); + bytes.extend(offset.to_le_bytes()); + bytes.extend([0; 2]); + bytes +} + +fn bounded_negative_compare(root: &Path, source: &Path) -> Output { + let stdout = root.join("negative.stdout"); + let stderr = root.join("negative.stderr"); + let mut child = Command::new(RELEASE_BIN) + .env_clear() + .current_dir(root) + .arg("ordinary-compare") + .arg("--input") + .arg(root.join("input")) + .arg("--archives") + .arg(root.join("archives")) + .arg("--source") + .arg(source) + .arg("--output") + .arg(root.join("matched")) + .stdout(Stdio::from( + fs::File::create(&stdout).expect("should create bounded stdout capture"), + )) + .stderr(Stdio::from( + fs::File::create(&stderr).expect("should create bounded stderr capture"), + )) + .spawn() + .expect("should execute the real comparator only in CI"); + let started = Instant::now(); + let status = loop { + let bounded = [&stdout, &stderr] + .into_iter() + .all(|path| fs::metadata(path).is_ok_and(|metadata| metadata.len() <= 32768)); + if !bounded || started.elapsed() > Duration::from_secs(10) { + // Reap even if the child exited between the cap check and kill. + let _ = child.kill(); + let reaped = child.wait(); + assert!(reaped.is_ok(), "should reap an over-bound comparator"); + assert!( + bounded && started.elapsed() <= Duration::from_secs(10), + "should finish the negative comparison within ten seconds and 32 KiB per stream" + ); + } + if let Some(status) = child.try_wait().expect("should poll the comparator") { + break status; + } + std::thread::sleep(Duration::from_millis(10)); + }; + let read = |path: &Path| { + assert!( + fs::metadata(path).is_ok_and(|metadata| metadata.len() <= 32768), + "should retain bounded post-exit output" + ); + let mut bytes = Vec::new(); + fs::File::open(path) + .expect("should open the retained output") + .take(32769) + .read_to_end(&mut bytes) + .expect("should read bounded output"); + assert!( + bytes.len() <= 32768, + "should refuse growth during the bounded read" + ); + bytes + }; + let stdout = read(&stdout); + let stderr = read(&stderr); + assert!( + started.elapsed() <= Duration::from_secs(10), + "should finish bounded reads within the comparison deadline" + ); + Output { + status, + stdout, + stderr, + } +} + +#[test] +fn ordinary_compare_mismatch_writes_no_companion() { + comparison_mismatch(false); +} + +#[test] +fn ordinary_package_mismatch_writes_no_companion() { + comparison_mismatch(true); +} + +fn comparison_mismatch(package_only: bool) { + let temporary = tempdir().expect("should create a CI-only negative fixture"); + let root = fs::canonicalize(temporary.path()).expect("should resolve the negative fixture"); + let measured = ordinary_measurement(&root, "printf 'v24.20.0\\n'"); + assert!( + measured.status.success(), + "should establish valid ordinary inputs before changing only final bytes: {}", + stderr(&measured) + ); + let source = root.join("workspace/ordinary-canonical-source"); + let binary = fs::read(source.join("release-assets/trusted-server-fastly.wasm")) + .expect("should read the measured final fixture"); + let different = if package_only { + binary.clone() + } else { + [binary.as_slice(), b"\0\x02\x01x"].concat() + }; + let right_package = if package_only { + b"changed package".as_slice() + } else { + b"package".as_slice() + }; + let canonical: Value = serde_json::from_slice( + &fs::read(root.join("observed/fastly-observation.json")) + .expect("should retain actual observation"), + ) + .expect("should parse the actual observation"); + let mut reproduction = canonical.clone(); + reproduction["role"] = json!("reproduction"); + reproduction["environment"]["checkout_root"] = + json!(root.join("workspace/ordinary-reproduction-source")); + reproduction["environment"]["target_root"] = + json!(root.join("temp/ordinary-fastly-reproduction-target")); + reproduction["environment"]["cargo_home"] = + json!(root.join("temp/ordinary-reproduction-cargo-home")); + reproduction["environment"]["started_at_seconds"] = json!(1_700_000_900_u64); + reproduction["environment"]["jobs"] = json!(2); + reproduction["environment"]["umask"] = json!(23); + reproduction["artifact"] = + json!({"size":different.len(),"sha256":hex::encode(Sha256::digest(&different))}); + let canonical = + serde_json::to_vec(&canonical).expect("should encode the retained canonical facts"); + reproduction["package"] = + json!({"size":right_package.len(),"sha256":hex::encode(Sha256::digest(right_package))}); + let reproduction = serde_json::to_vec(&reproduction) + .expect("should encode correctly measured different final bytes"); + let archives = [ + ordinary_fixture_zip(&[ + ("trusted-server-fastly.wasm", &binary), + ("trusted-server-fastly.wasm.spdx.json", b"{}"), + ("trusted-server-fastly.tar.gz", b"package"), + ]), + ordinary_fixture_zip(&[("fastly-observation.json", &canonical)]), + ordinary_fixture_zip(&[ + ("trusted-server-fastly.wasm", &different), + ("trusted-server-fastly.wasm.spdx.json", b"{}"), + ("trusted-server-fastly.tar.gz", right_package), + ]), + ordinary_fixture_zip(&[("fastly-observation.json", &reproduction)]), + ]; + let mut context = json!({"repository":"example-owner/example-server","repository_id":17,"head_repository_id":17,"source_commit":"a".repeat(40),"run_id":42,"run_attempt":1,"canonical":{},"reproduction":{}}); + let mut artifact_records = Vec::new(); + for (index, ((name, created), bytes)) in [ + ("release-trusted-server-fastly.wasm", "2023-11-14T22:13:25Z"), + ("fastly-observation-canonical", "2023-11-14T22:13:26Z"), + ( + "reproduction-trusted-server-fastly.wasm", + "2023-11-14T22:28:25Z", + ), + ("fastly-observation-reproduction", "2023-11-14T22:28:26Z"), + ] + .into_iter() + .zip(&archives) + .enumerate() + { + let digest = hex::encode(Sha256::digest(bytes)); + let id = 200 + index; + let role = if index < 2 { + "canonical" + } else { + "reproduction" + }; + let prefix = if index % 2 == 0 { + "artifact" + } else { + "observation" + }; + context[role][format!("{prefix}_id")] = json!(id); + context[role][format!("{prefix}_digest")] = json!(digest); + artifact_records.push(json!({"id":id,"name":name,"digest":format!("sha256:{digest}"),"size_in_bytes":bytes.len(),"expired":false,"created_at":created,"workflow_run":{"id":42,"repository_id":17,"head_repository_id":17,"head_sha":"a".repeat(40)}})); + } + let jobs: Vec<_> = [ + (100,"Build trusted-server-fastly.wasm","canonical","2023-11-14T22:13:10Z","2023-11-14T22:13:30Z"), + (101,"Reproduce trusted-server-fastly.wasm","reproduction","2023-11-14T22:28:10Z","2023-11-14T22:28:30Z"), + ].into_iter().map(|(id,name,role,start,end)| json!({"id":id,"name":format!("build / {name}"),"run_id":42,"run_attempt":1,"head_sha":"a".repeat(40),"status":"completed","conclusion":"success","started_at":start,"completed_at":end,"steps":[{"name":format!("Upload {role} release artifact"),"status":"completed","conclusion":"success"},{"name":format!("Upload {role} observation"),"status":"completed","conclusion":"success"}]})).collect(); + for (file, value) in [ + ("context.json", context), + ("jobs.json", json!({"total_count":jobs.len(),"jobs":jobs})), + ( + "artifacts.json", + json!({"total_count":artifact_records.len(),"artifacts":artifact_records}), + ), + ] { + write( + &root, + &format!("input/{file}"), + &serde_json::to_vec(&value).expect("should encode valid synthetic service records"), + ); + } + for (file, bytes) in [ + "canonical.zip", + "canonical-observation.zip", + "reproduction.zip", + "reproduction-observation.zip", + ] + .into_iter() + .zip(archives) + { + write(&root, &format!("archives/{file}"), &bytes); + } + let result = bounded_negative_compare(&root, &source); + if result.status.success() { + write(&root, "signing-sentinel", b"unexpected signing stage"); + write( + &root, + "publication-sentinel", + b"unexpected publication stage", + ); + } + assert!( + !result.status.success(), + "should reject complete differing final bytes" + ); + assert!( + if package_only { + stderr(&result).contains("deployment package final-byte mismatch") + } else { + stderr(&result).contains("ordinary final-byte mismatch") + }, + "should reach actual equality, not fail on fixture setup: {}", + stderr(&result) + ); + assert!( + stderr(&result).starts_with("Ordinary Fastly rebuild bytes differ"), + "should name the reproducibility failure first: {}", + stderr(&result) + ); + let diagnostic: Value = serde_json::from_slice( + &fs::read(root.join("ordinary-diagnostics/ordinary-comparison-diagnostic.json")) + .expect("should retain the real comparator's structured failure evidence"), + ) + .expect("should parse bounded CI-only diagnostic JSON"); + assert_eq!( + diagnostic["outcome"], "mismatched", + "should report actual mismatch" + ); + assert_eq!( + diagnostic["stage"], "bytes", + "should reach complete-byte comparison" + ); + assert_eq!( + diagnostic["code"], "final_bytes_mismatch", + "should retain fixed mismatch code" + ); + assert_eq!( + diagnostic["asset"], + if package_only { + "trusted-server-fastly.tar.gz" + } else { + "trusted-server-fastly.wasm" + }, + "should identify the artifact whose bytes differ" + ); + assert!( + result.stdout.is_empty(), + "should emit no successful comparison output" + ); + for path in ["matched", "signing-sentinel", "publication-sentinel"] { + assert!( + !root.join(path).exists(), + "should leave no matched output or later stage after a failed exit" + ); + } +} diff --git a/docs/guide/edgezero.md b/docs/guide/edgezero.md index 2107f95f56..0722d86179 100644 --- a/docs/guide/edgezero.md +++ b/docs/guide/edgezero.md @@ -71,10 +71,19 @@ store before deletion, especially when using `--store` or `--no-env`. 4. Run `ts config push --adapter ADAPTER`. 5. Start or deploy the adapter and verify a non-health publisher route. A healthy endpoint alone may not prove that application configuration loaded. - Confirm that its `x-ts-version` response header reports the release you - deployed. + Confirm that its `x-ts-version` response header reports the source identity + you deployed. A canonical attested release uses the approved full source + commit; its external release tag is recorded in immutable release metadata. + Development source builds retain the override/tag/branch/short-commit + display fallback. The header alone does not verify artifact provenance. See [Configuration](/guide/configuration) for the field contract, the [CLI guide](/guide/cli) for exact command help, and [deployment guides](/guide/integrations-overview#adapter-support) for target behavior. + +Canonical Fastly release consumption has a separate +[manual verified-byte procedure](/guide/release-verification#deploy-the-verified-fastly-package). +Integrated attested deployment with preactivation checks and staging isolation +remains separate work; the managed source/application-release path is not proof +those guarantees exist. diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index 07d170861e..6139f6b2ff 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -624,7 +624,11 @@ TSJS_PREBUILT_DIR="$PWD/crates/trusted-server-js/dist" cargo build-fastly ``` `TSJS_PREBUILT_DIR` is checked the same way as a normal build: every expected -bundle must be present and non-empty. `TSJS_SKIP_BUILD` is no longer supported. +bundle must be present and non-empty. Inventory-absent development builds keep +unrelated non-bundle entries such as an external Prebid directory. Rust Cargo +builds reject ANY presence of `TSJS_SKIP_BUILD`, including empty, `0` and `1`. +Node development build/Vitest entry points preserve development behavior when +`TRUSTED_SERVER__RELEASE_BUILD` is absent. --- @@ -639,14 +643,45 @@ writes. | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tsjs: npm not found on PATH` | Node.js is not installed or not on `PATH`. Install Node.js, or set `TSJS_PREBUILT_DIR` as shown above. | | `tsjs: TSJS_PREBUILT_DIR must be an absolute path` | Cargo runs build scripts from `crates/trusted-server-js`, so a relative path would resolve there. Pass an absolute path, for example `"$PWD/crates/trusted-server-js/dist"`. | -| `tsjs: TSJS_SKIP_BUILD is no longer supported` | `TSJS_SKIP_BUILD=1` no longer skips the build. Unset it and use `TSJS_PREBUILT_DIR` instead. | +| `tsjs: TSJS_SKIP_BUILD is no longer supported whenever present` | Any present value, including empty, `0` or `1`, fails Rust Cargo builds. Unset it and use `TSJS_PREBUILT_DIR` instead. | | `tsjs: node_modules is out of date with package-lock.json` | Dependencies changed, for example after switching branches. Run `npm ci` in `crates/trusted-server-js/lib`. The check compares file times, so a checkout that rewrites an unchanged lockfile also triggers it; `npm ci` clears it. | | `tsjs: npm ci failed` | `node_modules` was missing and the automatic install failed. Run `npm ci` in `crates/trusted-server-js/lib` to see the error. | | `tsjs: invalid bundle set ... missing bundles` / `empty bundles` | The Node build or the `TSJS_PREBUILT_DIR` directory did not produce one `tsjs-.js` per `core` and `lib/src/integrations//index.ts`. | | `tsjs: invalid bundle set ... unexpected bundles` | `TSJS_PREBUILT_DIR` holds bundles from a different source tree. Rebuild it with `npm run build`. | +When `TRUSTED_SERVER__RELEASE_BUILD=1`, the source must be a full lowercase +40-character `TRUSTED_SERVER__GIT_VERSION`; missing/malformed source and other +present release-control values fail without Git fallback. Ordinary release builds run npm and generate fresh private bundles; their release +script refuses prebuilt overrides. An explicitly selected prebuilt release path +requires both absolute `TSJS_PREBUILT_DIR` and +`TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` paths and does not run npm. + +Canonical `x-ts-version` reports that full source commit, while the external +release tag belongs to immutable release metadata. Development builds retain the +override/tag/branch/short-commit display fallback. The header alone does not +authenticate executable bytes; use [release verification](/guide/release-verification). + +| Error message contains | Cause and fix | +| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tsjs: release builds require TSJS_PREBUILT_DIR` / `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` | Supply both absolute paths from the checked producer output. | +| `tsjs: TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY requires TSJS_PREBUILT_DIR` | An inventory cannot select bundles without a prebuilt directory. | +| `tsjs: TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY must be an absolute path` | Pass the absolute sibling inventory pathname. | +| `tsjs: invalid inventory path` | Inventory must be outside the exact bundle directory and a regular single-link file. | +| `tsjs: invalid inventory` / `invalid prebuilt inventory inputs` | Fix source/module order, byte sizes/digests or unsafe entries. The checked path rejects bundle links, hard links, empty/extra entries and changed copied bytes. | + +Inventory-present development builds use the same strict checked path; the +inventory validates its own source syntax/hash, while the development source +display label is not compared with its commit. Strict checked-copy support is +Linux/macOS; inventory-absent development follows the existing portable path. +The exact raw inventory digest is exposed as +`TRUSTED_SERVER__TSJS_INVENTORY_SHA256` for composition evidence. + Set `TSJS_TEST=1` to run the TypeScript tests before the build; a failing test -fails the build. +fails the build. Controlled JS preparation still pins esbuild 0.27.3 while the +current lock selects 0.28.2. Its full collector fixture is visibly disabled, and +`TRUSTED_SERVER__RELEASE_JS_TESTS=1` fails against the unsupported lock. Ordinary +release builds and the Gate 1 Fastly A/B adapter do not use that observer. See the +[compatibility gap](https://github.com/IABTechLab/trusted-server/blob/main/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md#controlled-js-observer-gap). --- diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9e759697c5..ec4538fc36 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -237,6 +237,16 @@ and a build outside a git checkout omits the header. The value is public on every response, so pass a neutral value such as the commit hash when a branch name is sensitive. +This is the source-build development path. Canonical attested release builds +use `TRUSTED_SERVER__RELEASE_BUILD=1` with an approved full 40-character source +commit and no Git display fallback. Their `x-ts-version` is that source identity; +the external release tag is recorded separately in immutable release metadata. +The header is not a binary checksum or attestation verification. + +For canonical Fastly bytes, use the separate +[manual verified-byte production procedure](/guide/release-verification#deploy-the-verified-fastly-package). +The source-build commands above do not establish canonical release provenance. + ## Next Steps - Learn about [Edge Cookies](/guide/edge-cookies) diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index 9e8f833daa..7ea6ee1fa3 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -1,10 +1,11 @@ # Verify release binaries -Phase 1 publishes four binaries, four SPDX 2.3 SBOMs, eight Sigstore bundles -and `SHA256SUMS`: **17 immutable release assets**. Each binary has SLSA build -provenance and an SBOM attestation. Verify the downloaded bytes before execution -or deployment. Reproducibility, complete composition and the custom Trusted -Server verifier remain later phases. +Each release publishes four binaries, four SPDX 2.3 SBOMs, eight Sigstore +bundles, the Fastly deployment package, rebuild check and `SHA256SUMS`: **19 immutable release +assets**. Each binary has SLSA build provenance and an SBOM attestation. Verify +the downloaded bytes before execution or deployment. Fastly's ordinary A/B +equality gate is described below; controlled reproducibility qualification, +complete composition and the custom Trusted Server verifier remain later work. The tag caller is `.github/workflows/release.yml`. Its local reusable `.github/workflows/release-build.yml` owns builds and signing at the same source @@ -17,12 +18,12 @@ them with the pinned Node and a checksum-verified GitHub CLI 2.102.0. ## Platforms and coverage -| Asset | Phase 1 baseline and qualification limit | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `trusted-server-fastly.wasm` | Fastly Compute, `wasm32-wasip1`, default features; reproducibility and hosted runtime qualification remain pending | -| `ts-x86_64-unknown-linux-gnu` | GNU x86-64; UBI 9 builder; maximum required glibc 2.34; Debian 12, Ubuntu 22.04 and UBI 9 startup smokes | -| `ts-aarch64-apple-darwin` | macOS 15 Apple Silicon runner, deployment target 13.0; runtime compatibility qualification remains pending | -| `ts-x86_64-apple-darwin` | macOS 15 Intel runner, deployment target 13.0; runtime compatibility qualification remains pending | +| Asset | Phase 1 baseline and qualification limit | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `trusted-server-fastly.wasm` | Fastly Compute, `wasm32-wasip1`, default features; ordinary A/B byte equality required, controlled reproducibility and hosted runtime qualification pending | +| `ts-x86_64-unknown-linux-gnu` | GNU x86-64; UBI 9 builder; maximum required glibc 2.34; Debian 12, Ubuntu 22.04 and UBI 9 startup smokes | +| `ts-aarch64-apple-darwin` | macOS 15 Apple Silicon runner, deployment target 13.0; runtime compatibility qualification remains pending | +| `ts-x86_64-apple-darwin` | macOS 15 Intel runner, deployment target 13.0; runtime compatibility qualification remains pending | The build checks 64-bit Mach-O magic, target CPU and minimum macOS 13.0 load commands. Actual execution on macOS 13 remains later qualification. The @@ -47,7 +48,7 @@ linking, tree-shaking or minification. The installed `prebid.js` package is a build-tree entry; the external Prebid bundle is not embedded in tsjs. Native C/assembly, system libraries, Rust's standard library and compiler runtimes do not have complete coverage. Exact retained-code attribution and complete native -and tool closure remain Phase 2 work. An SBOM or provenance result is not a +and tool closure remain later controlled-stage work. An SBOM or provenance result is not a vulnerability scan. ## Download and verify @@ -67,7 +68,7 @@ approval. Run this in a new private directory, using Bash. Replace the fictional tag and commit. For rehearsal, change `REPO` to the approved public sandbox and use the -individually approved, never-reused rehearsal tag. +fresh lightweight, never-reused rehearsal tag. @@ -94,22 +95,132 @@ for asset in "${ASSETS[@]}"; do if [[ "$kind" == sbom ]]; then jq -e --slurpfile sbom "$asset.spdx.json" '[.[].verificationResult.statement.predicate] | length == 1 and .[0] == $sbom[0]' "$asset.$kind.verified.json"; fi done done +CHECK=trusted-server-fastly.wasm.rebuild-check.json +gh release verify-asset "$TAG" "$CHECK" --repo "$REPO" +gh attestation verify "$CHECK" --repo "$REPO" --bundle trusted-server-fastly.wasm.provenance.sigstore.json \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" \ + --deny-self-hosted-runners --predicate-type https://slsa.dev/provenance/v1 --signer-workflow "$WORKFLOW" +binary_sha=$(shasum -a 256 trusted-server-fastly.wasm | awk '{print $1}') +jq -e --arg source "$COMMIT" --arg digest "$binary_sha" '.version == 1 and .profile == "ordinary-release-fastly-byte-equality-v1" and .release_qualification == false and .outcome == "matched" and .source_commit == $source and .asset == "trusted-server-fastly.wasm" and .canonical_sha256 == $digest and .reproduction_sha256 == $digest' "$CHECK" +PACKAGE=trusted-server-fastly.tar.gz +gh release verify-asset "$TAG" "$PACKAGE" --repo "$REPO" +for kind in provenance sbom; do + predicate=https://slsa.dev/provenance/v1; if [[ "$kind" == sbom ]]; then predicate=https://spdx.dev/Document/v2.3; fi + gh attestation verify "$PACKAGE" --repo "$REPO" --bundle "trusted-server-fastly.wasm.$kind.sigstore.json" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" \ + --deny-self-hosted-runners --predicate-type "$predicate" --signer-workflow "$WORKFLOW" +done +package_sha=$(shasum -a 256 "$PACKAGE" | awk '{print $1}') +jq -e --arg digest "$package_sha" --argjson size "$(wc -c < "$PACKAGE" | tr -d ' ')" '.package.asset == "trusted-server-fastly.tar.gz" and .package.size == $size and .package.canonical_sha256 == $digest and .package.reproduction_sha256 == $digest' "$CHECK" gh release verify-asset "$TAG" SHA256SUMS --repo "$REPO" shasum -a 256 --check SHA256SUMS ``` -Provenance covers the binary and its SPDX document; the SBOM attestation covers -the binary. The verified SBOM predicate must equal the shipped SPDX document. +Provenance covers the binary and its SPDX document, plus the rebuild check for +Fastly. Fastly provenance also covers `trusted-server-fastly.tar.gz`; its SBOM attestation covers both the wasm and package. Other SBOM attestations cover their binary. The verified SBOM predicate must equal the shipped SPDX document. `--signer-workflow` pins the reusable signer; with the source ref and both digests pinned to the reviewed tag and commit, it identifies the exact signing workflow run's source. -The checksums cover the other 16 assets, never `SHA256SUMS` itself. Immutable +The checksums cover the other 18 assets, never `SHA256SUMS` itself. Immutable release verification establishes membership; provenance establishes the reviewed signer and source. Both are required. +## Fastly's ordinary rebuild check + +The release workflow runs the equality gate on every push to `main` and on every +`v*` tag, with no separate switch; `RELEASE_PUBLICATION_ENABLED` still gates +only signing and publication. Each run adds a second fresh Fastly build, after +the first completes, and a read-only comparison. Both full final Wasm files must +match byte for byte. A mismatch fails the run: on `main` it leaves that commit +without the passing release checks required before tagging, and on a tag it +prevents all signing and publication. Debug information and custom sections are +retained. The CLI binaries have no A/B release gate; native reproduction remains +later measured coverage. + +The builds vary physical source/target directories, Cargo home, job count, +umask and start time. Each role fetches its dependency sources into its own +fresh Cargo home under `RUNNER_TEMP`. Both receive the identical six-map +target-scoped Cargo argument pair: their checkout roots map to +`/trusted-server`, their target roots map to `/cargo-target`, and their Cargo +homes map to `/cargo-home`. Normalized compiler source-path identity is +intentionally shared. The retained argument bytes are checked against both +observations, each observation proves that every resolved package came from the +checkout or that role's Cargo home, and the complete binaries are scanned for +all six physical roots. Both jobs still run as the same runner user on the same +hosted image. This is ordinary equality between two jobs, not an independently +reproducible build recipe, with `release_qualification:false`; controlled +material/tool/boundary closure and an alternate user remain unqualified. + +`trusted-server-fastly.wasm.rebuild-check.json` is a third subject in the +existing Fastly provenance bundle. Existing binary/SPDX commands and signer +identity stay unchanged. Consumers must check the companion's `version` and +`profile`, not infer controlled qualification from `outcome:"matched"`. Phase 3 +will index this same filename; later controlled evidence uses a different +profile, with reviewed compatible joins or an explicit version change. + +To verify the companion through the online attestation store, omit the bundle +and keep every identity/source constraint: + +```bash +checks=(--repo "$REPO" --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" + --signer-digest "$COMMIT" --deny-self-hosted-runners + --predicate-type https://slsa.dev/provenance/v1) +gh attestation verify "$CHECK" "${checks[@]}" --signer-workflow "$WORKFLOW" +``` + +The same omission performs online lookup for each binary's provenance. For +online SBOM lookup, first authenticate the published SPDX file with the +release/asset checks above, then compare it only with results that `gh` has +successfully verified with every existing pin: + +```bash +set -euo pipefail +checks=(--repo "$REPO" --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" + --signer-digest "$COMMIT" --deny-self-hosted-runners + --predicate-type https://spdx.dev/Document/v2.3) +for asset in "${ASSETS[@]}"; do + gh attestation verify "$asset" "${checks[@]}" --signer-workflow "$WORKFLOW" \ + --format json > "$asset.online-sbom-verification.json" + jq -e --slurpfile document "$asset.spdx.json" \ + 'any(.[]; .verificationResult.statement.predicate == $document[0])' \ + "$asset.online-sbom-verification.json" +done +``` + +Reruns or retries of signing can leave more than one valid SBOM attestation for +the same binary digest, in no guaranteed order. Require a verified predicate +exactly equal to the authenticated published SPDX; do not choose the first or +newest record. `any` over an empty array is false, and `jq -e` exits non-zero +for an empty or nonmatching result. The published-bundle checks above and below +retain their single-result comparison. + +For offline companion verification, first obtain its SHA-256 independently as +`APPROVED_REBUILD_CHECK_SHA256SUMS`, then transfer the companion, Fastly +provenance bundle and trusted roots: + +```bash +set -euo pipefail +CHECK=trusted-server-fastly.wasm.rebuild-check.json +shasum -a 256 --check APPROVED_REBUILD_CHECK_SHA256SUMS +gh attestation verify "$CHECK" --repo "$REPO" --source-ref "refs/tags/$TAG" \ + --source-digest "$COMMIT" --signer-digest "$COMMIT" --deny-self-hosted-runners \ + --predicate-type https://slsa.dev/provenance/v1 \ + --bundle trusted-server-fastly.wasm.provenance.sigstore.json \ + --custom-trusted-root trusted_root.jsonl --signer-workflow "$WORKFLOW" +``` + +Acquire the roots as described below before disconnecting. Keep the companion's +profile/source/binary-digest check and the separate online immutable-membership +record with the deployment evidence. The signing job accepts a companion sealed +in this run's current or an earlier attempt, so "Re-run failed jobs" can retry +a failed signing leg without rebuilding. A rerun that includes the comparison +binds the build jobs GitHub carries over into the new attempt, which are still +this run's real pair. An existing immutable release is refused rather than +published again. + Stop if any check fails. Keep the outputs, reviewed source identity, bundles and checksums with your deployment record. These checks do not approve a publisher service, runtime configuration or deployment operation. See the @@ -129,7 +240,7 @@ gh attestation trusted-root > trusted_root.jsonl shasum -a 256 trusted_root.jsonl ``` -Transfer the binaries, their bundles, published SPDX documents, approved source +Transfer the binaries, Fastly package and rebuild check, their bundles, published SPDX documents, approved source pins and trusted roots to the offline machine. Shell variables do not transfer: set `REPO`, `TAG`, `COMMIT`, `ASSETS` and `WORKFLOW` again on the offline machine, from the same independently reviewed values. Supplying both the bundle @@ -138,7 +249,7 @@ and custom roots avoids network acquisition; there is no `--offline` flag. Before disconnecting, obtain an independent trusted SHA-256 pin for each binary (for example from a separately authenticated release verification record). Do not treat SHA256SUMS delivered with the same untrusted download as that pin. -Transfer that approved pin file as `APPROVED_BINARY_SHA256SUMS`. +Transfer that approved pin file as `APPROVED_BINARY_SHA256SUMS`, and independently approved package and companion pins as `APPROVED_FASTLY_SHA256SUMS`. ```bash set -euo pipefail @@ -160,6 +271,20 @@ for asset in "${ASSETS[@]}"; do fi done done +shasum -a 256 --check APPROVED_FASTLY_SHA256SUMS +CHECK=trusted-server-fastly.wasm.rebuild-check.json +PACKAGE=trusted-server-fastly.tar.gz +for subject in "$CHECK" "$PACKAGE"; do + gh attestation verify "$subject" --repo "$REPO" --bundle trusted-server-fastly.wasm.provenance.sigstore.json \ + --custom-trusted-root trusted_root.jsonl --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ + --signer-digest "$COMMIT" --deny-self-hosted-runners --predicate-type https://slsa.dev/provenance/v1 \ + --signer-workflow "$WORKFLOW" +done +gh attestation verify "$PACKAGE" --repo "$REPO" --bundle trusted-server-fastly.wasm.sbom.sigstore.json \ + --custom-trusted-root trusted_root.jsonl --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ + --signer-digest "$COMMIT" --deny-self-hosted-runners --predicate-type https://spdx.dev/Document/v2.3 \ + --signer-workflow "$WORKFLOW" --format json > package.offline-sbom-verification.json +jq -e --slurpfile document trusted-server-fastly.wasm.spdx.json 'length == 1 and .[0].verificationResult.statement.predicate == $document[0]' package.offline-sbom-verification.json ``` Offline success establishes signed build evidence for those retained bytes. @@ -177,7 +302,23 @@ copy that verified file into a directory on their `PATH` as `ts` and make it executable. Verify before first execution. Individual commands may still require a source checkout and provider tools; see [CLI](./cli.md). -## Deploy the verified Fastly wasm +## Deploy the verified Fastly package + +Download `trusted-server-fastly.tar.gz` with the other release assets and complete the provenance, SPDX, release-membership and checksum checks above before deployment. Both build jobs package their own wasm with the tracked `scripts/release/fastly.toml`: exactly `manifest_version = 3` and `name = "trusted-server-fastly"`. It contains no service ID, configuration, build hooks or credentials. The release gate compares the complete normalized tar/gzip bytes as well as the wasm; signing and publication refuse either mismatch. Owner/group, timestamp, paths, member order and permissions are fixed independently of the packing host. + +Use the pinned Fastly CLI 16.0.0. Select and review the destination service, integer base version and Config/Secret Store links first, as described below. In a fresh private directory containing only verified downloads: + +```bash +fastly compute validate --package ./trusted-server-fastly.tar.gz +tar -xOzf ./trusted-server-fastly.tar.gz trusted-server-fastly/bin/main.wasm > packaged.wasm +cmp trusted-server-fastly.wasm packaged.wasm +fastly compute deploy --package ./trusted-server-fastly.tar.gz \ + --service-id '99999' --version '7' --no-default-domain +``` + +Deploy the downloaded archive unchanged. The explicit service/version arguments keep operator selection outside the package. This command can activate production; retain the package digest, verified source, previous active version and observed result. The checks above establish signed build evidence and ordinary byte equality, not authorization to deploy or controlled-recipe qualification. + +### Repack verified wasm only as a fallback Keep the verified download unchanged. The ordinary source-build route does not establish equality with this release, and Phase 1 adds no `ts deploy --release` @@ -202,6 +343,14 @@ and actual configuration are runtime resources. This manual route does not create links, push configuration, implement staging, or supply recovery guarantees. +The canonical Fastly CI job validates and inspects the generated release package and also exercises this fallback route on its freshly built wasm, +using the `.tool-versions` CLI version and a source-pinned archive checksum. +After downloading the CLI, it clears inherited credentials and runs in a +network namespace with no active interfaces or retained capabilities. CLI +configuration and caches live only in the job's fresh temporary directory. +The job logs pack, validation, strict archive inspection and both byte comparisons; +the rehearsal transcript links that job. No deployment command is run. + From the private directory: ```bash @@ -266,8 +415,8 @@ records the input classes found by a one-time audit against an earlier main; it is not re-run automatically. It does not claim a complete native tool closure or hermetic build. -Phase 1 does not ship `release-index.json`, `tsjs-inventory.json`, build-evidence, -build-recipe or rebuild-check files. Their names remain reserved for later +Releases do not yet ship `release-index.json`, `tsjs-inventory.json`, +build-evidence or build-recipe files. Their names remain reserved for later phases. The build scrubs inherited environment values and scans every executable for @@ -293,9 +442,9 @@ maintainer decision; Phase 1 requires the tagged commit to be on some branch but does not check which one, and does not read rulesets. Before creating anything, the publisher runs `gh attestation verify` for every -binary and predicate against the signing bundles it is about to publish, with -the same pins as the commands above, and requires each signed SBOM predicate to -equal the shipped SPDX document. A failure there creates nothing. +binary and predicate, and for the Fastly rebuild check, against the signing +bundles it is about to publish, with the same pins as the commands above, and +requires each signed SBOM predicate to equal the shipped SPDX document. A failure there creates nothing. Publication then lists releases, including drafts, and refuses every existing matching tag. It creates one draft, logs and retains its release ID, and binds @@ -307,7 +456,7 @@ and digests. The reviewed publication owner remains later work. There are no automatic mutation retries or draft-resume claims. -The eight pre-creation `gh attestation verify` runs, the tag-reference read, the +The eleven pre-creation `gh attestation verify` runs, the tag-reference read, the release listing before creation and every create/upload/publish mutation remain single-shot. Each pre-creation verification times out after 60s. Every API read, including that listing, times out after at most 10s. Mutations have no @@ -368,8 +517,8 @@ to recover a failed read-only check. After a failed or indeterminate mutation, inspect the recorded release ID and all matching drafts read-only. Do not rerun uploads, delete a release, or reuse the tag without an independently approved recovery decision. Published immutable -releases are never overwritten. A fresh release needs a fresh approved tag; -Phase 2's full reconciliation and incident owner remains deferred. +releases are never overwritten. A fresh release needs a fresh approved tag. +Full reconciliation and an incident owner remain later work. Phase 1 accepts only lightweight tags whose raw tag reference points directly to the expected source commit. Annotated tags have a separate tag-object identity @@ -398,7 +547,9 @@ commands below only inspect settings; this workflow applies none of them. See [repository ruleset configuration](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository). - Before tagging, confirm that the release build checks passed on the `main` push of the commit you intend to tag: the `Binary attestations` workflow's - `build / checks` and four `build / Build ` check runs, which the + `build / checks`, four `build / Build `, + `build / Reproduce trusted-server-fastly.wasm` and + `build / Compare ordinary Fastly release bytes` check runs, which the `check-runs` command below lists. Release builds run only on `main` pushes and `v*` tags, so commits on other branches have no such checks, and a tag-triggered check cannot pass before its own tag exists. Before authorizing diff --git a/docs/superpowers/plans/binary-attestations-phase2-fastly-integration-implementation.md b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration-implementation.md new file mode 100644 index 0000000000..dcb7c1ce91 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration-implementation.md @@ -0,0 +1,209 @@ +# Ordinary Fastly release gate implementation plan + +## Current review amendment (2026-11-12) + +The [Phase 2 package amendment](../specs/2026-10-28-1235-binary-attestations-phase2-design.md#reproducible-deployment-package-amendment) supersedes the earlier producer-port and wasm-only asset/subject assignments below. Phase 2 retains only its live ordinary release path and Fastly packaging/inspection; unused controlled modules, tests, schemas and fixtures move to their enabling phase. `jsonschema` is removed and shipped dependency versions stay at `main`'s versions. Phase 3 #1463 must restore the deferred foundations it consumes; #1543 must reconcile the package artifact before landing. These branches are not changed here. + +The release now adds `trusted-server-fastly.tar.gz`, built reproducibly in both Fastly roles and compared alongside the wasm. Provenance covers wasm, SPDX, package and rebuild check; the SPDX attestation covers wasm and package. Existing binary/bundle names and the permanent companion remain stable. The set becomes **19 files**, with 18 checksum entries. Historical 18/30-file contracts, producer inventories and rehearsal results below describe their named revisions, not this amendment's candidate. Fresh current-input CI and signed-publication rehearsal are required; `.39` is historical evidence only. + +Implementation order: retain/extract live helpers and restore the lock; add deterministic package production and regression tests; extend observation, transfer and comparison; expand signer/publisher exact sets and operator verification; run local gates and internal review before signed publication; then qualify the actual new release inputs. A package-only mismatch fails closed. No deployment, production setting or controlled qualification is enabled. + +**Phase 2 spec:** [ordinary Fastly release equality](../specs/2026-10-28-1235-binary-attestations-phase2-design.md), with its own source-checked review rounds. Tracking issue: #1235. + +**Current state (2026-11-10):** this is the completed implementation record, implemented in #1449, which is open for review. Since approval, Phase 1 narrowed the release to four binaries and 18 files and removed the publisher's post-publish byte comparison, and release CI runs the gate on every push to `main` and every `v*` tag. Checked tasks and rehearsal sections below, including seven-binary and 30-file counts, are dated history for the source they name; the latest rehearsal before the fourth review round is `.30` at `44557229a`, and current rehearsal status lives in #1449 comments and #1235. + +**Goal:** Compare the actual canonical Fastly release bytes to a second fresh ordinary build, write and attest the approved version-1 informational rebuild-check, and block release on any mismatch. + +**Architecture:** Add an explicit ordinary comparison module to the producer, using strict documents, bounded reads, retained byte hashes and a separately identified ordinary receipt. No qualified/test-only recipe constructors become reachable. Keep the existing Phase 1 canonical producer and the reusable `sign` matrix identity. Run the second build and comparison inside the existing reusable workflow, then authenticate the additive companion as a provenance subject. + +**Activation:** After merge, release CI runs this on every push to `main` and every `v*` tag. There is no additional switch; the existing publication variable gates only signing and publication. Each run adds a fresh Fastly build and a comparison; signing waits for both builds and equality. A mismatch means no release. Rehearsal records the observed extra time, with existing 90-minute build and proposed ten-minute comparison ceilings. Do not weaken equality or add exclusions if a real mismatch occurs. + +## File boundaries + +| File | Responsibility | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-release/src/ordinary.rs` and private children | Strict ordinary observation/transfer models, identity join, final-byte comparison and v1 writer | +| Existing producer `main.rs`, `error.rs`, `Cargo.toml` | Explicit ordinary measurement/comparison commands and only their required dependencies/error variant | +| Existing `scripts/release/phase1-build.sh` | Retain Cargo JSON messages and the actual private JS output measurement for Fastly; preserve binary/SBOM production | +| Existing `scripts/release/phase1.mjs`, `phase1.test.mjs` | Bounded current-run API acquisition and workflow/publisher contract tests where needed; no new file under `scripts/release/` | +| Existing `release-build.yml` | A/B jobs, exact artifact retention, read-only comparison and same-job signing of the companion | +| Existing `release.yml` | Exact signed Fastly handoff, additive companion/checksum/release verification | +| Existing release guide, compatibility doc and phase plan | Ordinary claim, profile checks, companion commands, permanent name, activation/cost and recovery | + +Every new ordinary module path is Gate 1 implementation, distinct from frozen-source port paths and separately counted/reviewed. Keep the adapter in its own commit group. Do not copy collection/index/SPDX production implementations into Phase 2 as a shortcut. + +## Task 1 — Typed observations and deterministic input identity + +- [x] Add strict serde models with `deny_unknown_fields` for the approved v1 ordinary profile. Parse through the reviewed duplicate-key/size-limited document parser before deserializing. +- [x] Fix the asset/target/variant/profile values in source. Validate source SHA-1, SHA-256 fields, positive workflow/job/artifact IDs, distinct A/B jobs and artifacts, equal run/attempt, bounded lengths/sizes and canonical/reproduction roles. +- [x] Define the recipe identity as SHA-256 of reviewed canonical JSON containing version, source commit, package, target, release profile, default feature selection, `--locked`, and SHA-256s of `phase1-build.sh`, `.cargo/config.toml`, root `Cargo.toml`, Rust toolchain and tool-version files. All filenames are source-owned; input cannot add a command or path. +- [x] Represent prepared JS identity as sorted records of bundle filename, byte length and SHA-256, encoded through the same canonical JSON writer. The digest identifies exactly this observed set; it does not assert embedded membership or complete composition. Include the npm lock digest separately. +- [x] Record Rust/Node executable hashes, version output and acquisition context from each build. Require the approved compiler/Node pins and matching measured tool identities; a version string alone is insufficient. Record the unsupported qualification/material/boundary guarantees in nonempty gaps. +- [x] Record only an explicit environment allowlist: role, checkout/target roots, start time, umask, jobs, compiler locale/timezone and UID. Do not serialize inherited environment variables or secrets. Require the planned differing dimensions and identical normalized dimensions. Never claim compiler-visible path variation if only the host path changes. +- [x] Test deterministic encoding, exact fields/profile, duplicate keys, malformed IDs/digests, role/source/recipe/lock/tool/JS mismatches, missing gaps and environmental misstatements. Tests use fictional in-memory facts and bounded temporary files only. + +## Task 2 — Observe the actual Fastly build + +- [x] Add a producer measurement command that accepts only fixed Fastly role selection and explicit measurement/output paths. It reads the real source/lock/recipe/tool identities and emits one bounded observation sidecar; it constructs no qualified receipt and executes no arbitrary supplied command. +- [x] For Fastly, have `phase1-build.sh` retain the successful `cargo build --message-format=json-render-diagnostics` stream under the job's private target root, with shell `pipefail`. Preserve all existing build flags, canaries, source checks, smoke/SBOM steps and post-generation binary rehash. +- [x] Identify the JS build-script output by its exact Cargo package ID, matched against locked metadata. Require one unambiguous target-side build-script result under this fresh target tree. Resolve only its private `out/tsjs-dist`; reject aliases, escapes, duplicate outputs and missing/extra files. +- [x] Derive expected JS bundle names from the committed source module set. Hash the actual private bundles consumed by this build, not `dist/` or a separately rebuilt bundle set. Retain the measurement after checking the final binary is still unchanged. +- [x] Keep the public `release-` build artifact binary + SPDX only. Upload the Fastly observation as a separate current-run artifact; it is diagnostic input, not another release asset or complete JS inventory. +- [x] Audit pure measurement tests for no child process execution. Put real Cargo/npm/process integration tests in CI only. Reject invalid/freshness failures before invoking npm or Docker in developer tests. + +## Task 3 — Current-run transfer binding + +- [x] Extract canonical Fastly from the seven-build matrix into a dedicated `fastly-canonical` job named `Build trusted-server-fastly.wasm`; retain the other six binaries in the existing matrix. Add dedicated `fastly-reproduction` named `Reproduce trusted-server-fastly.wasm`, depending on canonical completion so measured whole-second start times are deliberately different. This is a sequential second-build cost, not a promise of parallel release latency. This is job topology only: the seven asset names and reusable `sign` job identity do not change. Run A and B as separate fresh hosted jobs of this reusable invocation, with disjoint checkout/output paths, deliberate job/umask variation and identical normalized compiler locale/timezone. The canonical A bytes remain the actual release binary. B uses the same full producer procedure; its SBOM is unshipped diagnostic output. +- [x] Each dedicated job exposes four direct outputs from its two upload-action steps: binary/SPDX archive ID and SHA-256, observation archive ID and SHA-256. Comparison receives all eight outputs through `needs.fastly-canonical.outputs` / `needs.fastly-reproduction.outputs`; no matrix last-writer output, observation-supplied ID or API-inferred job-to-artifact edge is accepted. Expected API job names are exactly `build / Build trusted-server-fastly.wasm` and `build / Reproduce trusted-server-fastly.wasm` under the unchanged caller job `build`. +- [x] Add `actions: read` to the existing `release.yml` reusable caller permission grant; reusable jobs cannot elevate beyond their caller. Keep write/OIDC/attestation permissions confined to their existing jobs. Give comparison only `contents: read` and `actions: read`. Fetch GitHub's current-run job and artifact records, using one bounded page of at most 100 records and rejecting truncation/duplicates. `total_count` must equal the returned list length; larger runs fail closed rather than fetching subsequent pages. Validate repository, source SHA, run ID/attempt, fixed expected job names, successful completion, artifact names/IDs and expiration. Never trust self-reported job IDs from observations. Read jobs from the exact current run-attempt endpoint and require exactly one successful instance of each expected job. Corroborate every upload output against artifact API ID/name/digest, source/run identity and server creation time within that producing job’s start/completion interval. Artifact APIs do not expose a producing job or run attempt, so the direct upload outputs are the required bridge. A rerun that includes the comparison binds the build jobs GitHub carries over into the new attempt, which are still this run's real pair; signing accepts a companion sealed in the current or an earlier attempt. +- [x] Fetch archives by those exact artifact IDs, enforce response/size/time bounds, and verify each archive's SHA-256 against GitHub's immutable artifact metadata before extracting. Do not treat the download action's warning-only digest behavior as a fail-closed check. +- [x] Reuse the frozen narrow ZIP32 parsing logic as a private ordinary transfer primitive with explicit provenance, without importing collection authority types or publishing its placeholder records. Retain its flat regular-file, duplicate/path/CRC/bounds checks and negative tests. If GitHub archives violate that profile, stop and report; do not loosen it silently. +- [x] Validate exact A binary/SPDX and A observation sets, exact B binary/SPDX and B observation sets, and bind all four current-run IDs/digests into checked comparison inputs. Download into fresh separate roots and rehash files before comparison. +- [x] Pure tests cover wrong run/attempt/source/job, duplicate names, foreign or expired IDs, digest mismatch, unsafe ZIP paths, aliases, extra/missing files and metadata/observation disagreement. External API/transport tests belong in CI. + +## Task 4 — Equality and real companion writer + +- [x] Add the explicit ordinary comparison command. Its only successful result joins the checked transfers and observations, reads the complete bounded A/B files, and compares sizes, recorded SHA-256s and actual bytes. No excluded region, normalization or mismatch fallback. +- [x] Factor reusable final-byte diagnostics from the reviewed comparison only if its existing owning-input checks remain unchanged. Do not construct `RecipeCompleted` or `FastlyEqualityChecked` from ordinary inputs. +- [x] Emit bounded first-difference diagnostics and retain a nonpublishable CI failure report on `mismatched`, `failed` or `unavailable`. A successful companion always says `matched`, carries actual IDs/digests/environment/inputs, and sets `release_qualification:false` with honest gaps. +- [x] Write the exact approved filename atomically into a fresh output directory only after equality succeeds. Require no preexisting output, regular nonempty bounded files, deterministic canonical JSON and exact source/asset joins. Rehash A immediately before handoff and bind the writer's canonical digest to it. +- [x] Test equal bytes with equal identities, equal bytes with mismatched inputs, size/hash/byte mismatches, binary mutation after measurement, alias/reuse of output, failure without publishable output, and bounded diagnostic length. Guard exact test selection locally; run the full module suite in CI. + +## Task 5 — Reusable workflow and unchanged signing identity + +- [x] Add reproduction and comparison jobs inside `release-build.yml`, without new triggers. Keep `release.yml` calling that file and the existing `sign` job/matrix calling `actions/attest`. No qualified selector or production recipe command is enabled. +- [x] The comparison needs the dedicated A and B jobs; the existing sign matrix needs both the six-native-build matrix and the comparison job. The existing sign matrix needs comparison too, so every entry waits for Fastly equality. The ordinary matrix builds each native asset once; the carried closed-producer reproduction measurement remains observational, never a new native equality gate. PR sign/publish conditions remain false. +- [x] Comparison uploads exactly one real `trusted-server-fastly.wasm.rebuild-check.json`. The Fastly sign entry downloads that artifact by exact name separately from the two original build inputs. Other sign entries keep their two inputs. +- [x] In the isolated sign step, check exact 3/2 input sets and regular nonempty files; require `version:1`, `outcome:"matched"`, expected profile/asset/target/default variant, `release_qualification:false`, current run/attempt/source and agreement between nested/top-level canonical digests and the actual binary. Build a receipt covering all input digests and recheck after signing. +- [x] Add the Fastly companion as the existing provenance action's third subject; preserve the same action/job/workflow and `.provenance.sigstore.json`. Keep binary-only SPDX attestation and SBOM bundle naming unchanged. Never checkout or execute build inputs in signing. +- [x] Include the already signed companion in Fastly's signed transfer artifact alongside the two real bundles and input/checksum receipt. Other signed transfer sets remain unchanged. The publisher still obtains the original canonical binary/SPDX from the original exact-name artifact. +- [x] Extend contract tests to prove all seven fixed assets remain identical across cohort/build/sign/publisher lists, tag/publication gates hold, sign has no checkout/producer call, and all signing dependencies fail closed on comparison failure. + +## Task 6 — Additive publication and operator verification + +- [x] Extend exact publisher input validation for Fastly's signed companion and receipt before any release mutation. Preserve the same original binary/SPDX copies and source identity. +- [x] Publish the 30-file set: seven binaries, seven SPDX files, fourteen genuine bundles, one Fastly rebuild-check, one `SHA256SUMS`. No index, fake bundle or complete-evidence claim. Checksum coverage remains every other published asset, excluding itself. +- [x] Preserve refuse-existing-draft/release logic, release-ID mutation, single-shot mutations, immutable/prerelease/Latest checks and bounded read-only recovery. Extend all membership, signed-input, upload, release-attestation and re-download checks to include the companion. +- [x] Extend existing failure-path tests for missing/extra/altered companion, unsigned receipt mismatch, failed comparison, unchanged native sets, 30-file release subjects and post-publish readback. Do not trim prior tests or weaken retries. +- [x] Add guide commands to verify the companion itself with its provenance bundle and online lookup, exact `release-build.yml` signer/source/certificate pins, release membership and checksums. Preserve existing binary provenance/SPDX commands verbatim and test that they still pass. +- [x] Document the permanent filename and required profile check: Phase 3 indexes these exact bytes; later controlled evidence uses a distinct profile in the same name, with compatible version-1 joins or a separately approved version change. +- [x] State merge activation, second-build/comparison cost, observed rehearsal timing and mismatch=no-release in the PR, guide and compatibility notes. Do not promise a separate activation switch. + +## Task 7 — Review, CI and rehearsal + +- [x] Independent review this plan, fix concrete issues and record the result before implementing. Review corrections: dedicated upload-output bridge instead of API-inferred job ownership; current-attempt rerun rejection; explicit negative CI process/canary proof; `actions:read` on both the existing reusable caller and comparison job. +- [x] Implement in separate reviewed adapter commits, each citing approved Gate 1 and frozen primitives reused. Keep larger source-only port commits separate. +- [x] Run audited pure tests with exact selection/nonzero guards, producer/CLI check and clippy, fmt, docs/markdown prettier, domain lint, actionlint and shellcheck as touched. No process-spawning/ignored tests locally. +- [x] Independent and security review the authority boundary, API identities, archive/input joins, writer, workflow permissions/signing/publication and failure semantics before pushing. +- [x] Wait for exact-head green CI, including real Linux processes and all seven builds; PR signing/publication must remain skipped. Use the next unused lightweight fork rehearsal tag only under the standing approval. No IAB tags/releases/settings or fork setting changes. +- [x] Add a CI-only negative fixture using the real ordinary comparison executable on deliberately different fixture bytes. Its bounded process harness records the expected nonzero comparison and proves signing/publication sentinel stages are never invoked. Read-only CI canary jobs and their audit were later removed in review: their condition was a hard-coded output, so they only showed that GitHub skips a job whose `if:` is false. Contract tests instead require the actual release sign job to depend on successful comparison and the publisher to depend on that reusable workflow. Report this as negative CI fixture proof, not a deliberately failed release rehearsal. Do not add a release-path bypass, new trigger, or altered-source release tag. Retain CI-only cancellation/output-bound process tests. +- [x] Rehearse the current reviewed supervision-fix head after green CI (`.20` at `5163241b9`, full transcript below): 30 exact assets, immutable/prerelease/not-Latest, source/transfer joins, A/B equality, genuine receipt, all online/offline predicates and memberships, signed sums/readback, SPDX equality, native smoke and pinned Fastly packaging/runtime without deployment. Historical `.16` remains valid only at its recorded source; its runtime supplement does not rehearse the later code fix. +- [x] On a real mismatch, retain bounded diagnostics and stop for a user decision; never add exclusions or relax equality. On any other failed rehearsal, fix within scope after diagnosis and retry only at a fresh sequential tag after green CI. +- [x] Post full transcript/results, actual size counts and source citations on #1449. Part of #1235; no separate tracking issue is required. + +## Proposed normalization amendment after `cc7df930f` + +**Status:** Approved after independent review at `604d5d869d194ebfb0dd19554cc2810eb10f4812`. Implementation preserves every final byte in the comparison and retains debug information. A remaining real A/B difference stops the attempt for another decision. + +### Observed failure and remaining uncertainty + +[Run 40382445644](https://github.com/IABTechLab/trusted-server/actions/runs/40382445644) built all seven assets and the second Fastly build. All four real upload archives passed the current-run identity/digest and ZIP checks. Equality then failed, and signing/publication were skipped: + +| Measurement | Canonical | Reproduction | +| ----------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Size | 80,019,161 | 80,019,324 | +| SHA-256 | `5c5dc9a78db8ad2af05a37a823756277ec72600eca48912886872d64d160416e` | `14595f7709e46db8ff46778a9d4e85a36719572477d2b93d56459d68170b9f19` | + +The first difference is at offset 10,264,685. Read-only section analysis found identical standard sections, including code and data; differences are confined to `.debug_info`, `.debug_str` and `name`. The two checkouts' distinct absolute paths appear in debug strings. The string/name sections account for +3/+160 bytes, and 711 of 12,076 function names differ; sampled differences are LLVM-generated suffixes. This supports a path-sensitive metadata hypothesis, but does not establish that remapping will fix every suffix. A new real A/B run must settle that. + +### Build-side experiment for approval + +Preserve `debug = 1`, all Wasm sections, target/profile/default features, `--locked`, jobs/umask variation and fresh roots. Restrict the experiment to Fastly's Rust target compilation. Both jobs receive the same ordered four-map vector (extended to six maps by the [review amendment](binary-attestations-phase2-fastly-integration.md#approved-phase-2-rebuild-check-compatibility), which adds each role's private Cargo home mapped to `/cargo-home`): + +| Physical prefix | Logical prefix | +| ----------------------------- | ----------------- | +| Canonical checkout | `/trusted-server` | +| Reproduction checkout | `/trusted-server` | +| Canonical target directory | `/cargo-target` | +| Reproduction target directory | `/cargo-target` | + +Derive the four physical prefixes from the source-owned sibling names already used by `release-build.yml`. Require canonical workspace/temp parents, the expected role's actual checkout/target roots, pairwise disjoint mappings and safely encodable text. Reject `=`, quotes, backslashes and control characters in ordinary physical prefixes; `=` would make rustc's `FROM=TO` parsing ambiguous. Do not change the controlled path grammar silently. The peer root need not exist. Require both observations to reconstruct identical raw argument bytes; unexpected differing workspace/temp bases fail rather than being silently normalized away. + +Use target-scoped `cargo --config target."wasm32-wasip1".rustflags=[...]`. Reuse the destinations and precedence/configuration rules from frozen `122b78664b1235c717e9208fd200f9a9b0c335df`'s `recipe/remap.rs`. Factor only its pure argument serialization if sharing is needed; preserve its controlled five-root behavior. Do not fabricate controlled vendor/tool roots or construct controlled recipe/qualified values. Reject extensionless source Cargo config, `build.rustflags` and ambient flag overrides. Preserve reviewed array-form target flags through Cargo concatenation; reject string-form or unsupported representations. Reject preexisting remap options in source target/triple/`cfg(...)` flag entries rather than guessing their effective order. The current source config contains no rustflags. Do not use `RUSTFLAGS` or `CARGO_ENCODED_RUSTFLAGS`, which would replace configured flags. + +### Concrete implementation steps after approval + +1. Add a private ordinary layout/flags planner and explicit ordinary flags command in the existing producer. It validates the fixed roles/roots and source Cargo configuration, then emits only the single target-scoped configuration value. The shell passes it as one quoted `--config` argument; no `eval` or arbitrary command is accepted. +2. Extend only the Fastly branch of `phase1-build.sh`: create its already-checked fresh target root, obtain the validated flags from the separately built producer, and pass them to the actual locked Cargo build. Keep native procedures and all configuration-canary/SBOM/binary checks unchanged. No new release script or policy/config file is needed. +3. Add the stable normalization-policy description to the ordinary recipe identity. Keep actual physical roots in observations. Before executing Cargo, retain the exact argument pair actually passed to it, encoded with the existing canonical JSON writer as `["--config", value]` in UTF-8 with its trailing LF. Hash those retained bytes in private CI observations; do not merely regenerate the observation after the build. Comparison independently reconstructs the expected pair from both roles' recorded roots and validates both private digests. Keep this physical-path digest outside the common semantic recipe identity and preserve the published v1 companion fields, filename, profile and `release_qualification:false` contract. Recipe/script/config changes remain bound by the source commit and `recipe_sha256`. +4. State the narrower variation claim: filesystem/invocation roots differ, while Rust source locations are intentionally remapped to shared logical prefixes. This does not vary the normalized compiler source-path identity. Controlled path closure, tool/material qualification and alternate-user coverage remain unproven. +5. Scan the complete final Wasm for all four physical prefixes and fail if any survives. Reuse the existing complete-byte path scan and bounded Wasm section parser where possible. Require retained `.debug_info`, `.debug_str` and `.debug_line` sections and the mapped source prefix inside `.debug_str`. Verify the target mapping in the argument vector; do not force an otherwise-unused target string into the binary. No section is rewritten or excluded. + +### Verification and stop conditions + +- Pure regressions: fixed ordering/destinations; identical four-map arguments for both roles; unexpected/overlapping/unsafe roots and unsupported configuration rejected; configured target flags preserved; semantic policy changes alter the recipe identity; private argument observations agree with both roles' roots. +- Keep every equality regression, including custom-section mismatch rejection. Add raw-prefix survival failures without stripping or changing those tests. +- Run the real hosted A/B procedure. If any bytes or LLVM suffixes still differ, retain bounded evidence and stop for another decision. Do not disable debug information, force crate metadata, rename symbols, rewrite Wasm, exclude sections or normalize the comparator's inputs. +- After exact-head green CI, use a fresh fork rehearsal tag. Repeat real archive validation, unchanged binary verification and additive companion verification. +- Exercise “re-run all jobs” and record the current-attempt/duplicate-artifact result separately from publication. Duplicate-check failure is a stop condition; do not choose a newest artifact, alter names or loosen joins. An already-published immutable tag must still refuse a new publication, so a full published-tag rerun is not expected to publish again. If the existing triggers cannot provide the desired prepublication attempt, ask for a rehearsal setup decision rather than changing triggers or settings. + +Rehearsal `.10` at `3ccfe29715a073bd37010ea86037b9c8f89801c7` passed real ZIP binding, complete-byte equality, signing and immutable publication of 30 assets. The [full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6438603951) checks all seven binaries online and from bundles, exact signer/source pins, SPDX equality, the companion, release membership, checksums and native/packaging evidence. + +Its [rerun-all experiment](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6439423827) passed current-attempt transfer and comparison without weakening duplicate or ZIP checks. Signing succeeded; publication then refused the existing immutable release before mutation. All 30 asset IDs, sizes and digests remained unchanged. The overall rerun is red because that refusal is required; it is not a second publication success. Reproduction took 293 seconds and comparison 91 seconds in the first attempt, overlapping native builds. + +The Phase 1 merge at `770a425df4b28caaaef68dfd930bceabd9928046` changes the reviewed head. Its Intel CI runner lost communication; a single failed-job retry passed without source, runner or timeout changes. Exact-head CI is green, with PR signing/publication skipped. Fresh rehearsal `.13` of that merged implementation passed publication and [full independent verification](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6441704601): all 30 files, seven binaries and the real companion, both predicates and identity modes online/offline, SPDX equality, checksum/metadata joins, native checks and the pinned offline Fastly route. Reproduction took 378 seconds and comparison 116 seconds, overlapping native builds. The earlier `.10` proof remains separately attributed to its own source; no current-head rerun-all claim is substituted for it. + +## Final implementation review record + +Two separate read-only implementation reviews assessed `8a797dbcb0467b867dfbcc571597110b9254d672` on 2026-10-28. These are risk-based reviews, not a line-by-line certification of the entire source port. Neither review executed tests or substituted for exact-head CI/rehearsal. + +- Independent producer review: assessed source authentication/materialization, policy/preflight/tool selectors, material/archive retention, Docker inspected configuration and process/environment boundaries, JS preparation/contributions, recipe execution/completion and composition joins. No actionable finding established. Qualified acquisition/image/tools/sandbox types remain uninhabited, and test-only selectors remain inaccessible in production. Full vendor normalization, native inventories, binary inspectors, macOS survivor enumeration and runtime startup were not exhaustively reviewed. +- Security review: assessed both release workflows, authenticated acquisition/redirect handling, current-run/attempt/job/direct-upload/API joins, fixed-member bounded archives, observed input identities, complete-byte comparison, atomic output/readback/rollback, isolated signing and publisher failure paths. No confirmed security blocker found in those paths. Actual GitHub service semantics remain runtime dependencies. Controlled-recipe preparation and the remainder of the producer port were outside this security review. +- Fresh spec review separately identified documentation/implementation discrepancies: actual run metadata is capped at a single complete page of 100 records; structured failure diagnostics were initially an open design discrepancy, subsequently approved for completion as recorded below. The phase spec records that round and its corrections. The subsequently completed current-input rehearsal `.16` is recorded below; `.13` is not expanded to later inputs. + +## Structured failure diagnostic completion + +The 2026-10-28 spec review found that bounded stderr did not fulfill the approved structured-diagnostic requirement. The user approved completing that existing requirement before a fresh rehearsal, without changing the approved Gate 1 design. + +- Keep comparison failures as errors. Attach a private typed diagnostic at the actual rejection boundary, with fixed stage/code pairs; never infer classification by matching human stderr. +- Write canonical JSON under `ordinary-diagnostics/ordinary-comparison-diagnostic.json`, outside the matched companion directory. Cap it at 4,096 bytes. Record only validated artifact IDs and actually measured binary sizes/digests/difference offsets and bounded byte windows. Every unobserved field is explicitly `unavailable`. +- Distinguish transfer unavailability/rejection, input rejection, identity mismatch, observed size/digest disagreement and complete-byte mismatch. Differing accurately observed final lengths still reach the byte-mismatch path. +- On failure, retain this JSON and bounded human stderr as a CI-only failure artifact. Acquisition failure before comparator execution gets an unavailable-transfer diagnostic with no invented facts. A diagnostic writer failure logs outcome `failed` and cannot make a failed job pass. Neither diagnostic is signed, selected as a companion or published. +- Pure tests cover typed stage/code paths, actual versus unavailable facts, the hard cap, no diagnostic on success and no companion on failure. The existing hosted negative process fixture must assert outcome `mismatched` and stage `bytes`. Run no local child-process test. +- Independently review the final diagnostic flow, run touched gates, wait for exact-head green CI, then rehearse a fresh lightweight fork tag. Record the exact source; do not expand `.13` attribution. + +Final diagnostic review found and corrected two completeness issues: unsafe/oversized transfers now differ from unavailable transfers, and failures after equality retain actual binary sizes/digests rather than unavailable markers. The independent security follow-up found no remaining blockers in these corrections; permissions and all signing/publication gates are unchanged. A partial fallback diagnostic can remain if its direct Node write is interrupted; the job remains failed and this evidence cannot authorize release. + +Local completion checks for diagnostic commit `f2b2fa87e01c1e0e0a1b18ae155bb944c909294b`: 36 explicitly selected pure Rust tests (11 command, 15 comparison, two diagnostic, eight transfer), 34 pure/mocked Node tests, strict host all-target/all-feature producer clippy, Rust/selected-file formatting, domain lint against the Phase 1 base, actionlint and shellcheck passed. Local process tests were excluded; the changed negative command fixture remains a hosted CI requirement. Documentation lint, format and build passed in an isolated dependency installation using the pinned Node version. These local checks are distinct from the subsequently completed hosted CI and rehearsal recorded below. + +## Exact-source completion evidence + +Rehearsal `.16` passed at `5fa3843364f247fdc261c438ab9257323637a42c`: all 18 jobs, 30 immutable prerelease assets (not Latest), 29 checksum entries, both binary predicates and both reusable signer identities online/offline, 14 SPDX equalities, exact one-byte negative, companion API/upload joins, native smoke/minimum-OS/build-facts checks and the pinned offline Fastly route. [Transcript parts 1](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467368408), [2](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467383159), [3](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467383676). Reproduction took 269 seconds; comparison 86 seconds, overlapping native builds. + +The first independent release download timed out; 28 local assets already matched API size/digests and the two missing files were recovered read-only. No workflow, release or tag was rerun or replaced. The exact-source hosted negative fixture passed with JSON outcome `mismatched` and stage `bytes`, preserving the signing/publication sentinel proof. + +Implementation CI: [Binary attestations](https://github.com/IABTechLab/trusted-server/actions/runs/40664514307), [Tests](https://github.com/IABTechLab/trusted-server/actions/runs/40664514177), [Format](https://github.com/IABTechLab/trusted-server/actions/runs/40664514166), [Integration](https://github.com/IABTechLab/trusted-server/actions/runs/40664514193): 29 successful checks and four configured skips. The `fb4843355` completion record was docs-only since this rehearsed SHA; the later source correction and its fresh rehearsal are recorded below. + +## Review follow-up: exact runtime proof and process identity + +The original `.16` transcript proved packaging, not execution of its published Wasm. The [2026-10-28 runtime supplement](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6471330921) reauthenticated the immutable release asset and exact reusable signer/source pins, then ran those unchanged bytes under pinned Viceroy 0.21.0 with private loopback configuration. SHA-256 before/after was `c02b5fecdf82a194ed62873549efc1489d6333eff1e2fa1285728465b60fa7b9`. Three publisher responses and the actual injected-JS route returned 200 with full `x-ts-version: 5fa3843364f247fdc261c438ab9257323637a42c`; the origin observed all three requests. Actual request/response logging was visible with zero `Build facts:` lines. Wrong-source and copied-log sensitivity controls rejected their deliberate errors. Owned listeners were stopped. This proves the exact local Viceroy scope, not deployed Fastly or browser behavior; no personal Fastly configuration was changed. + +The review also confirmed that process cleanup repeated `SIGKILL` after reaping its child, when the numeric process-group identity was no longer owned. The correction retains the initial pinned kill and bounded reaping, then uses only `killpg(pgid, 0)` for disappearance confirmation. A recycled ID can yield a bounded false cleanup failure, never another destructive signal. The two-second grace, real errno handling and fail-closed results are retained. A discriminating pure test failed on signal 9 before the correction, then all five injected-syscall tests passed. Real normal-exit/descendant, timeout, cancellation and escaped-pipe cases stay in hosted CI; a focused macOS step complements the existing Linux suite. Exact-head CI and independent fix review passed at `36454c526`; fresh rehearsal `.20` subsequently passed at `5163241b9`, as recorded below. + +The shared ledger and JSON landing metadata now consistently name draft Phase 3 #1463. That tracking correction authorizes no later implementation or gate. + +### Current-source rehearsal capacity failures + +Both `.17` and `.18` use the unchanged reviewed source `36454c526f70f145eb42a7994a8b6e74dc1df135`. Their macOS ARM64 jobs were cancelled before acquiring any runner or executing a step; GitHub annotations cite hosted ARM64 capacity constraints. Fastly reproduction and complete-byte comparison passed in both attempts. The [final .17 failure record](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6472906727) reports nine successful jobs, one runner cancellation and signing/release skipped, with no draft or published release. `.18` has the same runner-acquisition failure; its final run and evidence record must be checked separately. Neither attempt is accepted as a full release rehearsal, and neither tag is reused, deleted or retargeted. No runner label, setting, cohort or comparison requirement changes to work around this failure. + +The implementation CI separately passed 952 producer unit tests with nine existing ignores, three boundary tests, 18 command tests and 38 JS fixtures; its macOS process selection passed 45 tests with no ignores. The five new pure syscall tests have a recorded signal-9 failure before the fix and signal-zero success after it. These results do not substitute for the missing successful fork publication/release verification at this source. + +## Current-source rehearsal .20 (2026-10-29) + +See the current-state note at the top of this plan. + +Canonical and reproduction Wasm SHA-256 was `4fe7204f17df07a392f4e4b64f31eebf3b02179e57abee689ab0094990d0a572` (80,207,071 bytes). Reproduction took 282 seconds and comparison 122 seconds, overlapping native builds. Checkout/target roots, job count (4 versus 2), umask (0022 versus 0027) and start time varied; UID, locale and timezone stayed equal. The profile remains `ordinary-release-fastly-byte-equality-v1` with `release_qualification:false`. Native reproduction and controlled qualification remain outside this ordinary gate. + +The local runtime proof used authenticated published CLI/Wasm, private fictional configuration and pinned Viceroy. Three publisher responses and the injected 34,617-byte JS returned 200 with the full source header. Raw logs show three request and three successful response entries, each also echoed to stdout; there are no build-fact lines. Wrong-source and copied-log sensitivity controls failed as intended. The Wasm digest stayed unchanged; owned listeners stopped. No deployed Fastly or browser-runtime coverage is claimed. + +[Attempt `.19`](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6487087085) remains a failed runner-acquisition attempt at `5163241b9`, with nine successful jobs, one cancellation before any ARM64 macOS step, two skipped signing/publication jobs and no release. Attempts `.17`/`.18` remain attributed to `36454c526`; `.16` remains at `5fa384336`. All tags are preserved; no runner, cohort or settings changes were made. diff --git a/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md new file mode 100644 index 0000000000..d6dd8446f5 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md @@ -0,0 +1,236 @@ +# Phase 2 Fastly release integration — Gate 1 + +## Current review amendment (2026-11-12) + +The [Phase 2 package amendment](../specs/2026-10-28-1235-binary-attestations-phase2-design.md#reproducible-deployment-package-amendment) supersedes the earlier producer-port and wasm-only asset/subject assignments below. Phase 2 retains only its live ordinary release path and Fastly packaging/inspection; unused controlled modules, tests, schemas and fixtures move to their enabling phase. `jsonschema` is removed and shipped dependency versions stay at `main`'s versions. Phase 3 #1463 must restore the deferred foundations it consumes; #1543 must reconcile the package artifact before landing. These branches are not changed here. + +The release now adds `trusted-server-fastly.tar.gz`, built reproducibly in both Fastly roles and compared alongside the wasm. Provenance covers wasm, SPDX, package and rebuild check; the SPDX attestation covers wasm and package. Existing binary/bundle names and the permanent companion remain stable. The set becomes **19 files**, with 18 checksum entries. Historical 18/30-file contracts, producer inventories and rehearsal results below describe their named revisions, not this amendment's candidate. Fresh current-input CI and signed-publication rehearsal are required; `.39` is historical evidence only. + +Implementation order: retain/extract live helpers and restore the lock; add deterministic package production and regression tests; extend observation, transfer and comparison; expand signer/publisher exact sets and operator verification; run local gates and internal review before signed publication; then qualify the actual new release inputs. A package-only mismatch fails closed. No deployment, production setting or controlled qualification is enabled. + +**Status:** Approved on 2026-10-26 for the ordinary-release adapter, writer, transfer binding and additive provenance subject, subject to the conditions below. The concrete implementation plan was written, independently reviewed and implemented. + +**Current state (2026-11-10):** this is the approved Gate 1 record, implemented in #1449, which is open for review. Since approval, Phase 1 narrowed the release to four binaries and 18 files and removed the publisher's post-publish byte comparison, and release CI runs the gate on every push to `main` and every `v*` tag. Rehearsal sections below are dated history for the source they name; the latest rehearsal before the fourth review round is `.30` at `44557229a`, and current rehearsal status lives in #1449 comments and #1235. + +**Goal:** Require a fresh Fastly A/B build comparison in the existing release path, publish an authenticated `trusted-server-fastly.wasm.rebuild-check.json`, and preserve the Phase 1 verification commands and identities. + +## Decision requested + +Approve or revise the ordinary-release comparison adapter below, including its explicitly narrower claim, receipt fields, transfer binding and additive signing inputs. This is a real equality gate for the existing ordinary release builds. It is **not** qualification of #1262's controlled recipes, sandbox, materials or alternate-user environment. If Phase 2 must ship the full controlled-recipe profile instead, defer wiring until its bootstrap/material/tool acquisition and unqualified execution authority have a separately approved design; do not open the existing qualified selectors to satisfy a deadline. + +Gate 1 approval authorizes writing/reviewing the concrete implementation plan and then implementing this ordinary adapter within the approved design; a material design change requires renewed approval. No adapter code may land before that approval. Approval of this design alone does not approve a new trigger, IAB setting change, production/qualified selection or qualification resource. The integration must first be implemented and rehearsed in the fork. **Merging Phase 2 is the maintainer activation decision:** release CI then runs the Fastly gate on every push to `main` and every `v*` tag, without a separate switch. Each run costs one additional fresh Fastly build plus comparison, and signing/publication waits for both builds. Budget up to the 90-minute build ceiling plus ten-minute comparison ceiling; observed added wall time must be reported from rehearsal. A mismatch means no release. + +## Source findings and alternatives + +Source: #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df`; Phase 1 baseline `a501d1ba6bde2db82546d7e16c93facab8c613e6`. + +| Approach | Benefit | Cost / restriction | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| **Recommended: ordinary release A/B adapter** | Adds a genuine final-byte gate around the current producer, without constructing qualified authority | Proves observed ordinary-build equality, not the complete controlled-recipe assurance profile; this narrower scope needs explicit approval | +| Wire controlled recipes directly | Uses owning recipe/composition receipts and the full checked-input comparison | Blocked: bootstrap, qualified tool/JS/alternate types and production execution remain closed; enabling them is outside this approval | +| Run ignored candidate tests as the publisher | Reuses existing diagnostic code quickly | Rejected: test-only constructors and fixtures cannot establish release authority or replace the real release path | + +`recipe/compare.rs::check_fastly_equality` validates roles, source, lockfile, materials, JS, Rust tools, ambient pairing and retained final bytes. Its `FastlyEqualityChecked` token has private fields and stronger semantics than a bare file comparison. `recipe/tools.rs`, `preparation.rs` and `recipe/ambient.rs` expose ordinary selectors only under `cfg(test)`; qualified types are uninhabited and reproduction selection fails closed. Do not move test constructors into production or forge those receipts. + +Reuse the reviewed strict document parser, bounded I/O/process primitives and final-byte diagnostic logic where their preconditions hold. If needed, factor the byte-comparison primitive beneath the existing recipe checker while preserving all of that checker's input checks and tests. An ordinary receipt must have its own explicit type; it cannot masquerade as `RecipeCompleted` or `FastlyEqualityChecked`. + +The frozen candidate rebuild renderer in `distribution/entry.rs` emits null workflow/transfer IDs and gap-labelled candidate evidence. It is not the release writer. The spec's R-INDEX table requires more facts; no closed standalone rebuild-check schema is retained in #1262. This proposal defines the Phase 2 writer contract below; it does not amend the four-entry release-index schema. + +## Execution and trust flow + +1. Keep the current canonical Fastly output as the released binary. Run canonical and reproduction builds as distinct fresh hosted jobs within the existing reusable `release-build.yml` invocation, from the same exact source commit. Neither job has signing, publication or write credentials. No persistent build cache or artifact from an earlier run supplies A or B. +2. Both jobs execute the same reviewed build procedure and flags. Preserve `--locked`, default features, immutable build facts and current Fastly target. Materialize separate checkouts/output roots. Use the same pinned Rust and Node versions, lockfiles and independently generated JS input measurements. No sandbox/qualified-material guarantee is inferred from hosted-runner isolation. +3. Record actual checkout/root identifiers, start times, umask, job count, locale/timezone and tool identity. Vary host roots and job count/umask deliberately; normalize compiler locale/timezone identically. Record any dimension that cannot be varied. Container-internal identical paths are not described as compiler-visible root variation. Same UID is an unsupported variation, not a passed alternate-user test. +4. Measure JS during each actual build: sorted bundle names, byte lengths and SHA-256s, plus source/package-lock identity. Both builds must retain the same prepared bundle set and digests before the final comparison. Reuse the reviewed inventory/material parsing when available; the interim Phase 1 CycloneDX-to-SPDX output is not a JS inventory. Do not claim an npm resolver graph proves embedded membership. A digest-only measurement cannot become the Phase 3 complete composition inventory. +5. Retain original runner/build observations and exact final artifacts. Current-run artifact IDs, immutable transfer digests, job IDs and source commit must be obtained from runner/API/upload outputs and cross-checked against GitHub's current-run job/artifact records. Never accept a build's self-reported job identity as authoritative. Download A and B by exact names into distinct directories and reject missing, extra, aliased, empty, oversized or digest-mismatched files. +6. A read-only comparison job checks identity, source, lock, recipe, tools and JS measurements before reading bounded complete final bytes. No excluded or normalized Wasm regions are allowed. Compare size, SHA-256 and bytes; on inequality emit bounded diagnostics including first offset/byte windows, without dumping environment secrets. A byte match over mismatched inputs still fails. +7. Only a matched comparison can produce the release companion. Hash and rehash the canonical artifact throughout the handoff. The companion refers to the exact canonical bytes that the existing isolated sign job receives, not another copy selected by filename alone. + +The ordinary adapter's transfer checks protect this run's A/B selection; they do not claim the later complete transfer-binding slice is shipped for every existing artifact. Both builds still execute reviewed source and dependencies; equality is not proof that source or dependencies are benign. + +## Rebuild-check writer contract + +Write strict, deterministic version-1 JSON with sorted keys and one trailing newline. Reject duplicate keys, unknown fields, non-finite numbers, invalid digest formats, excessive lengths, aliases and source/asset mismatches. Values below describe fields, not fabricated example observations. + +| Field | Required meaning | +| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | Integer `1` | +| `profile` | `ordinary-release-fastly-byte-equality-v1`; explicitly distinct from the destination controlled-recipe profile | +| `release_qualification` | Always `false` | +| `source_commit`, `asset`, `target`, `variant` | Exact source SHA, `trusted-server-fastly.wasm`, `wasm32-wasip1`, `default` | +| `recipe_sha256` | Digest of the exact reviewed build procedure and its flags; both sides must match | +| `cargo_lock_sha256`, `npm_lock_sha256`, `js_inventory_sha256` | Observed locked input identities and the shared measured prepared-JS identity; both sides must match | +| `tools` | Exact measured Rust/Node tool identities and acquisition source; no qualified status inferred from a version string | +| `canonical`, `reproduction` | Each contains current workflow run ID/attempt, producing job ID, artifact ID/name, transfer SHA-256, observed environment, final size and final SHA-256 | +| `outcome` | Closed enum `matched`, `mismatched`, `failed`, `unavailable`; only `matched` is publishable for Fastly | +| `canonical_sha256`, `reproduction_sha256` | Top-level join fields retained for existing informational consumers; must equal nested final digests | +| `gaps` | Honest remaining assurances, including unqualified tools/materials/boundary and unsupported environment variation; never empty merely because bytes match | + +For failed attempts, retain a separate bounded CI diagnostic using the same outcome vocabulary, error stage/code and available observed facts; unavailable facts are explicit and never replaced by invented IDs or digests. Failure diagnostics are not release companions. Never upload a placeholder/empty rebuild-check to satisfy the release file set. + +Recipe hashing needs an explicit canonical encoding of source-owned build inputs and flags. JS and tool digests must be measured in the actual jobs, with strict retention/rehash checks. Finalize this encoding and its discriminating tests in the implementation plan after design approval; do not infer it from mutable environment text. No new qualification pins/resources are silently supplied by this proposal. + +## Workflow changes after approval + +No `on:` trigger changes. Keep `release.yml` calling `release-build.yml`; keep the `sign` job in that reusable workflow, including its `RELEASE_PUBLICATION_ENABLED`/tag gate and permissions. Keep signing isolated from checkout or execution of build inputs. Ordinary PR builds never sign or publish. + +Inside `release-build.yml`, the fixed build cohort is four binaries (seven before the Phase 1 narrowing). Canonical Fastly becomes a dedicated job so its upload-action outputs bind unambiguously to its artifacts; the three CLI binaries remain in the matrix. Add the reproduction/comparison dependency only for Fastly. Keep the existing `sign` job and matrix; add the Fastly comparison as a job-level dependency, so every signing entry waits for the Fastly gate. Failure of either Fastly build or comparison prevents signing/publication for the release. This adds no native reproduction gate. Native builds retain their existing success requirements; native reproduction differences or absence do not become a new gate. Do not emit native rebuild companions until their later approved phase. + +For Fastly only, expand the exact unsigned input set from binary + SPDX to binary + SPDX + rebuild-check. Rehash all three inputs before/after signing and in the signed transfer receipt. The existing provenance attestation gains the rebuild-check as an additional subject; keep its bundle named `.provenance.sigstore.json`. Keep the binary-only SPDX attestation and `.sbom.sigstore.json` unchanged. All other assets' exact input/subject sets remain unchanged. + +In `release.yml`, preserve exact-name downloads into separate directories and validate the expanded Fastly receipt before release creation. Add only `trusted-server-fastly.wasm.rebuild-check.json` to publication: **17 existing assets become 18**. `SHA256SUMS` continues to cover every other published asset and never itself. Existing binary, SPDX and bundle names, SLSA/SPDX predicate URIs, reusable signer workflow/certificate identity, source pins and Phase 1 verification commands continue to work unchanged. + +Preserve single-shot listing/refusal, draft creation, uploads and publication by release ID; never resume or overwrite an existing tag/draft. Preserve prerelease/Latest behavior, immutable readback, strict release-attestation validation, bounded read-only retries, and release membership checks. Expand exact asset membership/digest expectations to 18. Existing immutable releases are untouched. + +## Failure behavior and bounds + +- A/B build, measurement, source/tool/input join, transfer check, runtime acceptance or equality failure prevents the companion/signing/publication path. There is no downgrade to unmatched or canonical-only Fastly release. +- Cancellation/timeouts fail closed and clean up owned work; they do not yield a successful receipt. Bound each build to the existing 90-minute build-job ceiling; comparison to ten minutes and bounded input sizes. Preserve the existing publication/verification deadlines rather than granting unlimited new retries. +- Retain bounded failure evidence as CI artifacts with existing seven-day retention. Logs name failed checks and observed digests/IDs; never print full inherited environments or tokens. +- Native reproduction remains observational. A native build/startup failure still fails its existing release checks; an attempted native reproduction reports its actual result without blocking on byte equality. +- Before publication, failures may leave a refused draft requiring explicit human inspection; never retry a mutation or delete automatically. After publication, a verification failure means the release is already immutable: report its release ID/tag and exact read-only recovery commands. Use the existing recovery guide; do not attempt a replacement under the same tag. + +## Verification and rehearsal plan + +1. Pure tests: writer positive contract; duplicate/malformed/extra fields; missing observations; wrong source, role, tool/JS/recipe identity; byte/size/digest mismatches; canonical changed after comparison; exact 18-file membership; checksum coverage; no successful receipt on failure. Every negative test must reach the intended validation rather than failing on an unrelated missing input. +2. CI-only process tests: real fresh A/B builds, altered ambient settings, deliberate mismatch/cancellation/output bounds and exact current-run transfer selection. Keep local tests pure and explicitly selected with a nonzero guard. All existing Phase 1 tests remain, including mutation/retry failure paths. +3. Independent implementation and security review of ordinary authority, observations, transfer binding, permissions, receipt signing, cleanup and publish failure paths. Confirm no qualified/test-only constructor is newly reachable. +4. After upstream CI is green on the exact reviewed head, use the next unused sequential lightweight fork rehearsal tag under the standing approval. Never tag, release or change settings in IAB; changes to fork settings still require approval. +5. Positive rehearsal: all four binaries build; show A/B source/input identity, measured environment variation and complete final-byte match; print the real receipt and verify its digest/provenance subject. Confirm 18 exact release assets, immutable and prerelease/not-Latest behavior, release download, checksums, all release memberships, both binary provenance/SPDX verifications offline and online with exact `release-build.yml` pins, and shipped SPDX equality. Repeat the manual pinned Fastly package/validate/inspect/byte comparison and runtime/source-header/no-request-facts-log checks without deploying. +6. Rehearsal failure proof: exercise a deterministic comparison mismatch in a CI-only negative fixture, proving no sign/publication dependent job runs. Do not push an intentionally altered source commit as a release rehearsal or add a debug bypass to the release path. A failed real rehearsal retains its tag/release and uses a fresh number only after fixing and obtaining exact-head green CI. +7. Post the transcript, tag/run link and pass/fail result on the Phase 2 PR. Do not claim the full controlled-recipe profile or Phase 2 readiness if only the ordinary bridge is approved/proven. + +## Approval checklist + +- [x] User accepts the ordinary-release adapter's narrower equality claim for this Phase 2 slice. +- [x] User approves the concrete informational writer/measurement/transfer contract and additive Fastly provenance subject. +- [x] User approves implementation after a concrete plan is written and independently reviewed within this design; qualification types/selectors and new triggers remain closed. +- [x] Rehearse the exact reviewed head and report time/cost and unchanged Phase 1 verification before merge. Merging Phase 2 runs the gate on every push to `main` and every `v*` tag; no separate switch. + +## Approved forward-compatibility and mismatch conditions + +The filename `trusted-server-fastly.wasm.rebuild-check.json` is permanent. Phase 3 indexes and digests these exact bytes as a rebuild-check companion. A later controlled-recipe implementation supersedes the `ordinary-release-fastly-byte-equality-v1` profile in the same asset, using a distinct profile value while retaining version-1 shared identity/outcome/digest fields where compatible. Consumers must check the profile before interpreting assurance; an ordinary profile never proves controlled-recipe qualification. Any incompatible informational format change needs a separately reviewed version change, not an implicit reinterpretation. + +Rehearsal must verify the rebuild-check file itself against its SLSA provenance subject using the same reusable signer/source pins as the binary, then verify release membership and its checksum. It must also run the existing binary provenance/SPDX commands unchanged after adding the subject. A real nondeterministic mismatch is a stop-and-report event: retain bounded diagnostics and ask for a decision. Never weaken byte comparison, normalize regions or add exclusions to obtain a match. + +## Compatibility and rehearsal record + +These sections moved here from the Phase 1 compatibility record, which Phase 1 +removed after its scope was narrowed. Rehearsals up to `.20` covered the earlier +seven-binary release (30 files with the rebuild check). + +### Phase 1 narrowing merge + +Merging the narrowed Phase 1 removes the three Axum binaries, the Rust source +audit and the publisher's post-publish re-download. The release is now four +binaries plus the rebuild check and `SHA256SUMS`: 18 files, with `SHA256SUMS` +covering the other 17. The publisher verifies every signing bundle before it +creates a draft, including the rebuild check against the Fastly provenance +bundle. + +Rehearsal `.26` of the merge commit `3ddcd1996` failed the canonical Fastly +build before anything was signed: Phase 1 had stopped writing +`cargo-metadata.json`, which the ordinary capture reads. `9eeccbb88` writes it +again and tests that the capture reads the file the acquisition step writes. +Rehearsal [`.27`](https://github.com/prk-Jr/trusted-server/actions/runs/41987840964) +at `9eeccbb88` then passed all 12 jobs: both Fastly builds matched +(`8fc36bb49a06f2182512d57976b26ee763f0ef06e9a40635f24540ef7c7980eb`; canonical +399s, reproduction 443s, comparison 129s), the publisher verified every bundle +and the rebuild check before creating the release, and it published 18 +immutable prerelease assets. The guide's verification block passed against +the published release. Offline verification and the local Viceroy runtime +check were not repeated. + +### Approved Phase 2 rebuild-check compatibility + +Gate 1's ordinary-release Fastly adapter was approved on 2026-10-26; implementation follows a separately reviewed concrete plan. The asset name `trusted-server-fastly.wasm.rebuild-check.json` stays fixed. Its version-1 `ordinary-release-fastly-byte-equality-v1` profile proves only the documented ordinary A/B comparison, with `release_qualification:false`. Consumers must check `profile`; matching digests do not imply controlled-recipe qualification. + +The approved build-side normalization amendment maps both actual checkout roots to `/trusted-server` and both target roots to `/cargo-target`, passing the identical four-map target-scoped Cargo argument pair to each build. Filesystem roots still differ; normalized compiler source-path identity deliberately does not. The exact argument pair is retained before compilation, checked against both observations and kept out of the public receipt and semantic recipe identity. The recipe identity includes the stable normalization policy. Complete final bytes retain their debug/name sections and must contain no physical root. Comparison excludes and rewrites no bytes; controlled tool/material/path closure and alternate-user coverage remain unqualified. A remaining real mismatch stops the attempt. + +**Review amendment (2026-11-10):** the #1449 maintainer review found that dependency sources under the shared runner `CARGO_HOME` stayed unmapped in the final bytes, so equality depended on that shared path. Each role now builds with its own fresh Cargo home beside its target root, and both homes map to `/cargo-home`. The map is six entries, observations record each role's Cargo home, the recorder requires every resolved package to come from the checkout or that home, and the binary scan covers all six physical roots. + +Phase 3 will index and digest this same companion. Later controlled-recipe evidence may supersede the ordinary profile in the same filename using a distinct profile value while preserving compatible version-1 source, outcome and digest joins. Incompatible informational changes need an explicit reviewed version change; neither a filename change nor silently stronger interpretation is allowed. + +The Fastly provenance attestation adds the rebuild-check subject while retaining the existing reusable signer identity and bundle name. Rehearsal must prove that existing binary verification commands pass unchanged and separately verify the rebuild-check's provenance, release membership and checksum. After merge, every push to `main` and every `v*` tag adds a second Fastly build and comparison before signing. A mismatch prevents release; retain bounded diagnostics and ask for a decision without exclusions or weaker comparison. + +#### Manual Fastly package directory headers + +[CI at `a8f8bcb81`](https://github.com/IABTechLab/trusted-server/actions/runs/40469201263/job/122071972950) confirmed that the pinned Fastly CLI emits exactly two zero-sized type-5 directory headers named `example-app` and `example-app/bin`, without trailing slashes. The other two headers are the regular wasm and manifest files; prefixes and link names are empty, and no hidden nonzero padding or metadata headers were present. + +The private read-only package inspector accepts those two known directory names with or without one trailing slash, only with type 5 and size zero. It deduplicates their logical identities, so mixing the two spellings cannot bypass duplicate rejection. File names/types, links, prefixes, metadata rejection, four-member requirement, expansion/trailer bounds and independent manifest/wasm hashes remain unchanged. Other path aliases remain rejected. This transport compatibility correction grants no recipe, runtime, official-release or deployment qualification. + +#### Phase 2 ordinary metadata and closed destination policy + +Phase 2 validates the ordinary cohort strictly. Since Phase 1 narrowed the release to four binaries, the ordinary cohort, the retained controlled recipes, the consumer policy and the index schema all cover the same four artifacts in fixed order. A row outside that set, such as a former Axum row, is refused as unknown. Earlier Phase 2 revisions validated seven ordinary rows; the rehearsals below that name seven binaries or 30 files record that earlier set. + +The carried tools and consumer policy name GitHub CLI 2.97.0 as a destination pin. Current operator instructions use 2.102.0, and the ordinary publisher records the installed version. Qualification pins, controlled reproducibility profiles and the destination GitHub CLI pin are not enforced by the ordinary producer. Its existing publication, signing and verification checks remain in force. + +#### Controlled JS observer gap + +Controlled JS preparation does not support the current npm lock: the frozen observer admits esbuild 0.27.3, while the lock selects 0.28.2. The observer is ported with one review change: the deletion pattern in `scripts/release/observe-npm.mjs` was rewritten in `f243408ce`; its esbuild admission is unchanged. Ordinary release builds and the Gate 1 Fastly A/B adapter do not use it. Adapting the observer belongs to the gated controlled-recipe work and requires its own review of the esbuild change. + +The full collector test stays ported with an explicit skipped reason naming both versions; no CI job claims it passes. A cheap configuration guard rejects `TRUSTED_SERVER__RELEASE_JS_TESTS=1` against an unsupported lock, and a pure regression test checks that refusal. Existing observer fixture tests and the ordinary/checked-prebuilt build fixtures remain separate. + +Unlike the frozen controlled producer, ordinary release mode may select neither prebuilt input and build fresh private bundles with npm. Explicit prebuilt release selection still requires both the absolute bundle directory and inventory, with source/hash, strict-copy and race checks unchanged. Phase 1 continues to refuse all prebuilt overrides. The build-input audit allows the inventory metadata name only in the exact JS build script, never in other build scripts or compile-time application reads. + +#### Online SBOM records after a signing rerun + +Reruns or retries of the signing workflow can leave more than one valid SBOM attestation for the same binary digest, in no guaranteed order. In rehearsal `.10` at `3ccfe29715a073bd37010ea86037b9c8f89801c7`, the second attempt passed archive binding and equality, then refused the existing immutable release. Both signing attempts remain visible online; their SPDX creation timestamps and document namespaces differ, while the published release and its assets remain unchanged. + +Online consumers keep every repository, workflow/certificate, tag/ref, source/signer digest and predicate pin. Only after `gh attestation verify` succeeds may `jq -e --slurpfile document .spdx.json` require `any(.[]; .verificationResult.statement.predicate == $document[0])`. The document must first pass authenticated release/asset verification. This selects a predicate exactly equal to the published SPDX without choosing the first/newest record. An empty result or no equal predicate evaluates false and exits non-zero. Published-bundle checks retain their strict single-result comparison; no workflow or signing behavior changes. + +#### Exact-head rehearsal evidence + +| Phase | Tag | Implementation source | Evidence | +| ----- | ----- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `.12` | `ab6e5493636fd3d1db4b22a071189070f90a189a` | [Passed: 29 immutable prerelease files and full independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6440880360) | +| 2 | `.10` | `3ccfe29715a073bd37010ea86037b9c8f89801c7` | [Passed: 30 files, unchanged binary verification and real companion](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6438603951); [rerun-all transfer passed, existing immutable release refused](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6439423827) | +| 2 | `.13` | `770a425df4b28caaaef68dfd930bceabd9928046` | [Passed: full 30-file publication and independent verification of the merged implementation](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6441704601) | + +Phase 1 `.11` failed at the single draft-create POST with HTTP 403; no release or draft existed. Fresh `.12` placed the same reviewed source at the fork Phase 1 branch head as well as a new lightweight tag and passed, without source, settings or token-permission changes. The failure log did not retain the response body, so the exact permission cause remains unconfirmed. The [failed attempt](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6439851715) remains recorded and its tag was not reused. Phase 1's bundle-based single-result examples remain unchanged. + +The Phase 2 `.10` reproduction job took 293 seconds and comparison 91 seconds, overlapping native builds. Its [online-SBOM positive and one-byte-negative checks](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6439619901) passed against fully verified pinned results. The negative copy remained valid JSON, changed exactly one byte, and returned false with `jq -e` exit 1; the authenticated published document remained untouched. The merged head's `.13` reproduction took 378 seconds and comparison 116 seconds, also overlapping native builds. These records identify their exact implementation SHAs; the completion commit `2ed1b941a212aeb27e08f8d1e27cb8353f40f535` was docs-only since rehearsed `770a425df4b28caaaef68dfd930bceabd9928046`. + +#### Phase 2 merge after rehearsal .13 + +This merge joins Phase 2 parent `2ed1b941a212aeb27e08f8d1e27cb8353f40f535` with updated Phase 1 parent `20152315c01dd5fddcfc84b969f8e9993b0bfc69`. Phase 1 carries the main integration rehearsed in `.15` at `68eef902a8bddd9024676cc3806fa5377ed67fee`, followed by its docs-only evidence update. + +The source-changing Phase 2 integration is `81020c0929bdc29ee4102e6516b7a75cc2cfe810`, a **no-release-machinery merge**: application code from main changes binary inputs, while release workflows, producer scripts, build facts, manifests/lock and tool pins retain their Phase 2 values. No new Phase 2 rehearsal is performed, as requested. `.13` stays attributed exclusively to `770a425df4b28caaaef68dfd930bceabd9928046`; its release is not evidence for binaries from this new merge head. Gate 1 activation and all closed qualification/design gates remain unchanged. + +This follow-up records only the exact merge SHA and is docs-only after `81020c0929bdc29ee4102e6516b7a75cc2cfe810`. The application changes since rehearsed `770a425df` remain unrehearsed. + +#### Newer stack trunk integration + +After main advanced, GitHub's stack CI evaluated Phase 2 against the newer trunk and found a conflict in `prebid_bundle.rs`. Integration `7d551777daeaee09e24bce9bf4f96edac6e5e0df` merged main `054c7b777ce67db34744cb1f4100b14e59c4bc5a` into Phase 2 parent `3ae0e191f321ed1d796af0106b9043804d7af452` only. The resolution retains both the upstream Unix permissions-test import and Phase 2's resource-discovery tests; upstream security fixes remain intact. + +Phase 1 stays at docs head `20152315c01dd5fddcfc84b969f8e9993b0bfc69`, with `.15` still attributed to `68eef902a8bddd9024676cc3806fa5377ed67fee`. The additional Phase 2 integration changes application inputs, with no release-machinery changes and no new Phase 2 rehearsal. `.13` remains exclusively at `770a425df4b28caaaef68dfd930bceabd9928046`. + +This exact-SHA record is docs-only after `7d551777daeaee09e24bce9bf4f96edac6e5e0df`; the later application inputs remain unrehearsed. + +### Phase 2 final review follow-up (2026-10-28) + +The parent tracking issue is #1235; the earlier separate-issue draft is historical. Phase 2 now has its own [spec and review log](../specs/2026-10-28-1235-binary-attestations-phase2-design.md), distinct from the Phase 1 review rounds. Current readiness work does not expand historical `.13` attribution. Fresh rehearsal `.16` subsequently passed at `5fa3843364f247fdc261c438ab9257323637a42c`, as recorded below. + +The structured failure diagnostic completion preserves all release contracts: canonical JSON is capped at 4,096 bytes, classified at actual typed checks and retained only as `ordinary-diagnostics/ordinary-comparison-diagnostic.json` in the failed comparison CI artifact, alongside bounded stderr. Unobserved facts are explicitly `unavailable`. A failure writes no matched companion; a diagnostic-writer error remains a failed job. These diagnostics are never signed or published. The existing companion filename, profile and exact-byte comparison are unchanged. + +The final risk-based security review also identified a dormant limitation in macOS survivor enumeration: a saturated PID buffer can be treated as a complete snapshot, and failed per-process identity queries become nonmatches. This is not part of the ordinary Fastly release path; it must be addressed before any later macOS containment qualification opens. Darwin startup currently provides scrubbed/supervised host execution, not filesystem/network confinement. Qualified selectors remain uninhabited; this batch does not enable or modify that dormant path. + +### Phase 2 historical rehearsal .16 + +Rehearsal `.16` passed at `5fa3843364f247fdc261c438ab9257323637a42c` with all 18 jobs successful. It covers the later application inputs and structured-diagnostic completion; the historical `.13` remains exclusively at `770a425df4b28caaaef68dfd930bceabd9928046`. The subsequent `fb4843355` completion record was docs-only since that exact rehearsed implementation; the later process-supervision correction is a separate source change. + +The [full transcript](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467368408) records 30 immutable prerelease assets, not Latest; 29 checksum entries; all seven binaries' SLSA/SPDX verification with reusable workflow and exact certificate/source/tag pins, online and offline; 14 SPDX equalities plus an exact one-byte negative; the genuine companion's provenance, source-owned recipe/lock hashes and all four upload/API/job joins; release download, membership and byte readback; macOS 13.0 metadata, CLI full commit, Axum non-loopback smoke and pinned Fastly pack/validate/inspect/cmp without deployment. Reproduction took 4m29s; comparison 1m26s, overlapping native builds. + +An initial independent download hit a connection timeout. The 28 complete local files were checked against API sizes/digests; the two missing files were recovered from the same immutable release with bounded read-only retries. The workflow, release and lightweight tag were not reused or changed. Hosted source tests also passed the real negative comparator's structured `mismatched` / `bytes` assertions and later-stage sentinel refusal. + +### Phase 2 review follow-up and rehearsal .20 + +**Verification at `5163241b9`:** Rehearsal `.20` passed at exact reviewed source `5163241b947b1aa335987bd832b9205e4364e4bb`: [all 18 jobs](https://github.com/prk-Jr/trusted-server/actions/runs/40812416995) succeeded, publishing 30 immutable prerelease assets, not Latest. Independent [transcript parts 1](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6487647974), [2](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6487649518) and [3](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6487649861) verify all release memberships and checksums, both predicates and reusable signer identities online/offline for all seven binaries, 14 SPDX equalities and negative controls, companion joins, native smoke and pinned Fastly packaging. The exact published Wasm also passed the local Viceroy 0.21.0 publisher/JS/source-header/logging check, with zero build-fact lines and owned-listener cleanup. Current-source CI passed 29 checks with four expected skips. This completion record is docs-only since rehearsed `5163241b9`; its subsequent hosted checks are assessed separately. + +The [`.16` runtime supplement](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6471330921) closes the original missing execution evidence for unchanged published bytes at `5fa384336`: three actual publisher responses and the injected JS route returned 200 with that full source header under pinned Viceroy 0.21.0. Actual request/response logs were visible and contained zero build-facts lines. Wrong-source and log-detector controls passed, the binary digest stayed unchanged, and owned listeners stopped. This is local Viceroy evidence, not deployed Fastly/browser qualification. The packaging inspector's `runtime_validated:false` still correctly describes that inspector; the separate execution check does not rewrite its output or any immutable asset. + +The optional process finding was confirmed and corrected in `927109fa1`: initial group killing occurs while the unreaped leader pins its identity, and all post-reap confirmation uses read-only signal zero. Numeric reuse can cause a bounded false cleanup failure but cannot receive another destructive signal. Five pure regressions and real hosted Linux/macOS process tests pass. Shared metadata consistently identifies Phase 3 draft #1463. + +Fresh `.17` and `.18` preserve the seven-binary cohort and reviewed source `36454c526`, but their ARM64 macOS jobs never acquired runners. This is external scheduling evidence, not an established code failure and not a successful publication rehearsal. Both tags are preserved. Existing `.16` signed/publication proof stays attributed only to its original source. No approval or implementation for later qualified, SPDX/index or consumer gates follows from these corrections. + +Fresh `.20` closes the post-correction rehearsal gate at `5163241b947b1aa335987bd832b9205e4364e4bb`. Its Wasm SHA-256 is `4fe7204f17df07a392f4e4b64f31eebf3b02179e57abee689ab0094990d0a572`. Reproduction took 282 seconds; comparison 122 seconds. Attempt `.19` at that same source had the same ARM64 runner-acquisition cancellation, with no release ([failure record](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6487087085)); it is not claimed as successful. Historical `.16`, `.17` and `.18` retain their existing source/result attribution. No later production/qualified or SPDX/index/consumer gate is opened. diff --git a/docs/superpowers/plans/binary-attestations-phases-2-4-tracking-issue.md b/docs/superpowers/plans/binary-attestations-phases-2-4-tracking-issue.md new file mode 100644 index 0000000000..4f02908bdc --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phases-2-4-tracking-issue.md @@ -0,0 +1,70 @@ +# Binary attestations: phases 2–4 + +Historical separate-issue draft. On 2026-10-28, the existing parent issue #1235 was selected to track this work; publication of another tracking issue is not required. + +## Summary + +Track the remaining work from #1262 as three stacked draft PRs, each checked and rehearsed before the next phase begins. Part of #1235; Phase 1 is #1435. Phase 1 remains draft for its maintainer review. + +The source is frozen at `122b78664b1235c717e9208fd200f9a9b0c335df`, with base `f9281917a7260b723f5f22175911c6d2147ef650`. The full [assignment and dependency evidence](binary-attestations-phases-2-4.md) records all 434 paths, carried work, estimates, staged test hunks and approval gates. Once posted, replace this relative link with the committed document link and put this issue's number in each phase PR. + +## Stack and acceptance criteria + +### Phase 2 — Reproducible Fastly builds + +Branch: `feature/binary-attestations-phase2`; base: `feature/binary-attestations-phase1`. + +- [ ] Port strict documents, policy/preflight/source, process supervision, sandbox boundaries, JS preparation, recipes and binary inspection from the recorded source. +- [ ] Include all non-SPDX composition capture modules and their recipe dependencies. +- [ ] Preserve already-landed immutable build facts; port only residual source-resource and integration-test changes. +- [ ] Obtain separate approval for the missing real equality-gate/rebuild-check integration before implementing it. +- [ ] Prove fresh canonical/reproduction Fastly builds are byte-identical and publish a real `.rebuild-check.json` companion. Native reproduction stays measured, not gated. +- [ ] Rehearse the new capability and unchanged Phase 1 verification before declaring the phase ready. + +### Phase 3 — Complete evidence and release index + +Branch: `feature/binary-attestations-phase3`; base: Phase 2 branch. + +- [ ] Port SPDX projections, collection, workflow-jobs/transfer validation and distribution/index code. +- [ ] Restore the recipe and native-composition SPDX/index/export assertions staged out of Phase 2; do not drop tests. +- [ ] Obtain approval for the complete-SPDX integration and owning composition inputs; the source generator is a closed stub. +- [ ] Draft and obtain approval for a seven-artifact index schema amendment before changing the schema or constructing the seven-artifact index. +- [ ] Obtain approval for the signed-index integration; publish only genuine signature bundles. +- [ ] Publish complete SPDX, `release-index.json`, `tsjs-inventory.json`, per-asset `.build-evidence.json`, `.build-recipe.json`, `.rebuild-check.json` and index provenance as additive real assets. +- [ ] Rehearse complete evidence and unchanged Phase 1 commands before declaring the phase ready. + +### Phase 4 — ts release verify + +Branch: `feature/binary-attestations-phase4`; base: Phase 3 branch. + +- [ ] Port consumer verification and publication-owner code with existing tests. +- [ ] Obtain separate approval for a public offline verification interface/trust boundary; the private source helper and manual commands are not substitutes. +- [ ] Obtain approval for live publication wiring and a trusted fork-rehearsal transport/policy mechanism. Keep official production repository/signer identity strict. +- [ ] Ship public online and offline `ts release verify`, clearly distinguishing offline build verification from online publication checks. +- [ ] Replace the interim publisher only after its replacement is rehearsed, retaining release-ID mutation, refusal and recovery guarantees. +- [ ] Rehearse the public CLI and unchanged Phase 1 verification before declaring the phase ready. + +## Fixed contract and qualification + +Preserve the reusable `release-build.yml` signer identity, four binary names, SPDX 2.3 predicate and companion names, signature-bundle names, `SHA256SUMS` rules, immutable build facts, runtime bind/port settings, publication/signing variable gate and stable/prerelease Latest behavior. New assets are additive. + +All uninhabited `Qualified*` enums remain uninhabited; production and qualified selectors remain closed. Ordinary-CI and rehearsal evidence remains `release_qualification:false`. New triggers, maintainer settings/authority, qualification resources, and missing integration designs require separate approval. + +The unpublished GNU masking WIP `b91c7450a4f617d5ec879f612b295a532c7d7efa` stays excluded and untouched. If wanted later, request approval and land its boundary/recipe coverage as a separate reviewed Phase 2 commit. + +## Verification and delivery + +- [ ] Record source SHAs, adaptations, actual new-file/addition counts and Rust versus workflow/script/test size in every PR. +- [ ] Keep process-spawning and ignored tests CI-only. Run selected pure tests locally with a nonzero-selection guard. +- [ ] Pass touched repository CI gates and target-matched check/clippy, formatting, documentation/domain lint, Node tests, shellcheck and actionlint as applicable. +- [ ] Complete independent and security review of boundary, transfer, signing, publication and verifier code. +- [ ] Rehearse only the exact reviewed head after upstream CI is green. Use fresh sequential lightweight tags in the fork only, never reused, deleted or retargeted; post full transcripts and pass/fail results. +- [ ] Keep PRs draft without reviewers. After lower merges, merge main into the stack and retarget the next base; no rebase or force-push. + +No IAB tags, releases or settings changes. Fork settings/variables/deletions need approval. Phase 1 helpers remain until their compatible replacements are rehearsed. + +## Current planning evidence + +Phase 2 alone and Phase 2 plus Phase 3 evidence code pass host all-target check and strict clippy on Rust 1.98.1. This compiles macOS test code; Linux-only code and process tests still need CI. It proves dependency closure, not qualification, behavioral correctness or completion of the missing integrations. + +Exclusive ledger: Phase 2 274 paths, Phase 3 74, Phase 4 54; 32 paths already carried in #1435. Frozen-source estimates: Phase 2 241 new files/79,907 text lines; Phase 3 72/20,438; Phase 4 45/14,985. Split assertions and current-main adaptations change actual per-PR sizes, which must be reported separately. diff --git a/docs/superpowers/plans/binary-attestations-phases-2-4.md b/docs/superpowers/plans/binary-attestations-phases-2-4.md new file mode 100644 index 0000000000..0384848b68 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phases-2-4.md @@ -0,0 +1,614 @@ +# Binary attestations phases 2–4 + +## Current review amendment (2026-11-12) + +The [Phase 2 package amendment](../specs/2026-10-28-1235-binary-attestations-phase2-design.md#reproducible-deployment-package-amendment) supersedes the earlier producer-port and wasm-only asset/subject assignments below. Phase 2 retains only its live ordinary release path and Fastly packaging/inspection; unused controlled modules, tests, schemas and fixtures move to their enabling phase. `jsonschema` is removed and shipped dependency versions stay at `main`'s versions. Phase 3 #1463 must restore the deferred foundations it consumes; #1543 must reconcile the package artifact before landing. These branches are not changed here. + +The release now adds `trusted-server-fastly.tar.gz`, built reproducibly in both Fastly roles and compared alongside the wasm. Provenance covers wasm, SPDX, package and rebuild check; the SPDX attestation covers wasm and package. Existing binary/bundle names and the permanent companion remain stable. The set becomes **19 files**, with 18 checksum entries. Historical 18/30-file contracts, producer inventories and rehearsal results below describe their named revisions, not this amendment's candidate. Fresh current-input CI and signed-publication rehearsal are required; `.39` is historical evidence only. + +Implementation order: retain/extract live helpers and restore the lock; add deterministic package production and regression tests; extend observation, transfer and comparison; expand signer/publisher exact sets and operator verification; run local gates and internal review before signed publication; then qualify the actual new release inputs. A package-only mismatch fails closed. No deployment, production setting or controlled qualification is enabled. + +**Phase 2 spec:** [ordinary Fastly release equality](../specs/2026-10-28-1235-binary-attestations-phase2-design.md), with its own source-checked review rounds. Tracking issue: #1235. + +**Current state (2026-11-10):** Phase 2 (#1449) is open for review. Since approval, Phase 1 narrowed the release to four binaries and 18 files and removed the publisher's post-publish byte comparison, and release CI runs the gate on every push to `main` and every `v*` tag. Rehearsal entries below are dated history for the source they name; the latest rehearsal before the fourth review round is `.30` at `44557229a`, and current rehearsal status lives in #1449 comments and #1235. + +**Tracking issue:** #1235. The earlier separate tracking-issue draft is retained as planning history, not an additional issue prerequisite. + +**Goal:** Land the remaining reviewed source as three stacked, independently checked draft PRs while preserving the Phase 1 release contract. + +## Source and stack + +Frozen source: `122b78664b1235c717e9208fd200f9a9b0c335df` from #1262; source-map base: `f9281917a7260b723f5f22175911c6d2147ef650`. A fresh read of #1262 on 2026-10-26 confirmed that its published head is still the frozen source. Introducing and shaping SHAs remain recorded per group in [the slice map](binary-attestations-slice-map.json); they are provenance references, not safe whole-commit cherry-pick boundaries. + +Current Phase 1 base: `4940928e800fed00e4bddd8f3727fb52dcb2b296`, containing later application inputs. Rehearsal `.15` remains attributed only to `68eef902a8bddd9024676cc3806fa5377ed67fee`. Phase 2 carries the updated base through tree-identical merge `8a797dbcb0467b867dfbcc571597110b9254d672`. Phase 2 `.13` stays attributed to `770a425df4b28caaaef68dfd930bceabd9928046`; the later application inputs and structured diagnostics are covered by `.16` at `5fa3843364f247fdc261c438ab9257323637a42c`. + +| Draft PR | Branch | Base | Intended capability | +| ------------------------------------------------------------------ | ------------------------------------ | ------------------------------------ | -------------------------------------------------------- | +| Binary attestations: phase 2 – reproducible Fastly builds | `feature/binary-attestations-phase2` | `feature/binary-attestations-phase1` | Fastly equality and a real rebuild-check companion | +| Binary attestations: phase 3 – complete evidence and release index | `feature/binary-attestations-phase3` | Phase 2 branch | Complete SPDX, evidence companions and a signed index | +| Binary attestations: phase 4 – ts release verify | `feature/binary-attestations-phase4` | Phase 3 branch | Public online/offline verification and publication owner | + +Finish and rehearse each phase before the next. When a lower phase merges, merge main into the stack and retarget the next base; never rebase or force-push. The intended capabilities above are acceptance criteria, not claims that the frozen source already implements their missing integrations. + +## Assignment corrections + +- Phase 2 owns every non-SPDX composition file: `composition.rs` and capture, external, graph, JavaScript, native, origin, platform, their tests and fixtures. The production recipe/composition dependency cycle closes within Phase 2. +- Phase 3 owns `spdx/**`, `collection/**`, `distribution/**`, their tests and fixtures. Shared SPDX schema/license and qualification-results schema land in Phase 2 because strict document validation embeds them; no SPDX generator is enabled by carrying schema data. +- The neutral `js_inventory_sha256` test helper moves from `recipe/tests/candidate_entry.rs` to `recipe/tests/candidate.rs` in Phase 2. The three recipe runners import it there. It needs only retained JS contributions. +- `recipe/tests/candidate_entry.rs`, `candidate_index.rs` and `spdx_candidate.rs` land in Phase 3. `candidate.rs` retains fixture parsing, measurements and recipe support in Phase 2; its export-after-cleanup wrappers, `RuntimeAcceptance` and export assertions land in Phase 3. +- In `recipe/tests/complete.rs`, Phase 2 keeps build/composition/JS rejection tests; Phase 3 adds the SPDX-generation rejection test and the collection-export assertion. `full.rs`, `cli.rs` and `macos_cli.rs` retain build, equality or native measurement, inspection, remapping and runtime/startup checks in Phase 2; Phase 3 restores SPDX/index/export assertions. No test is dropped from the final stack. +- Four `native_spdx_*` tests in `composition/native/tests.rs` land in Phase 3. Capture tests remain in Phase 2. `092de447b` removed the Phase 2-unused `fixture_for_projection` (`composition/native`), `fixture_native_registry` (`composition/origin.rs`) and `index_identity()` (`policy.rs`); Phase 3 still calls them (`distribution.rs`, `composition/native/tests.rs`, the frozen `spdx/native/tests.rs`), so the next Phase 2 merge into Phase 3 must restore them there or it will not compile. `recipe/startup/accepted/tests.rs` contains no evidence-module imports and stays wholly in Phase 2. +- `crates/trusted-server-cli/tests/fixtures/release/release-index.json` moves to Phase 3 because distribution tests embed it. CLI consumer code remains Phase 4. +- Three `workflow_contract_command_*` process tests in `tests/commands.rs` are restored in Phase 3 with the workflow-contract command; all other frozen command tests remain in Phase 2. +- Main command/module registrations, error variants, re-exports, manifests, lockfile, workflows and shared tests are staged by dependency. Stage unused evidence/publication variants until their owning phase rather than introducing broad warning suppressions. + +### Avoid duplicate build-facts ports + +Ten old slice 2.3 paths were handled in #1435. Eight landed: the CLI build script; core build script, Git resolver, library registration and resolver test; Fastly main; CLI build-facts test; core constants. Preserve the current logging and documentation refinements in the last three. Two were removed and are not carried: the Fastly build script (`07c2bdf19`) and the build-info module (`3e6bb0f09`). CLI `run.rs` already carries the labelled version output; Phase 4 ports only the remaining CLI command/module hunks. + +The six residual slice 2.3 paths are CLI resource discovery, four integration-test/helper paths and the historical Git-version design note. These remain Phase 2. The source-identical release-tools and CLI policy documents are retained, not copied again. Phase 1 has since narrowed the release to four binaries, so the restored four-artifact `release-cohort.json` matches the published set; do not overwrite the current domain allowlist with the frozen version. + +## Dependency closure evidence + +Scratch directories were assembled outside the checkout, without worktrees or branch changes to the frozen source. Inputs were the Phase 1 baseline above, main at `7aa5c993b` (its two additional changes update JS package versions/lock), and the assigned source files. The producer crate is registered in the current workspace manifest; dependencies are resolved with the current workspace versions. Rust 1.98.1 is explicit because this checkout has an older local override. + +| Scratch tree | Host check | Strict clippy | +| --------------------------------------------------------------- | ---------- | ------------- | +| Phase 2 alone | Pass | Pass | +| Phase 2 + Phase 3 evidence modules and restored test assertions | Pass | Pass | + +Commands for each tree: + +```sh +cargo +1.98.1 check -p trusted-server-release --all-targets --target aarch64-apple-darwin --locked --offline +cargo +1.98.1 clippy -p trusted-server-release --all-targets --all-features --target aarch64-apple-darwin --locked --offline -- -D warnings +``` + +Initial Phase 2 check had no unresolved imports after splitting the evidence tests. Strict clippy exposed unused destination-only re-exports, error variants and one macOS evidence-measurement parameter; these were staged or removed from the Phase 2 hunk. Phase 3 initially failed on its embedded CLI release-index fixture; assigning that prerequisite to Phase 3 fixed it. Final checks and clippy exited zero without warnings for both trees. + +This proves host compilation and lint closure, including macOS test compilation. It does not execute tests, cover Linux-only cfg branches, prove release-cohort behavior, qualify tools or prove a release capability. No process-spawning or ignored tests were run locally. Linux compilation and process tests belong in CI. The original frozen four-artifact fixtures/schema remain historical candidate data; index construction waits for Phase 3 approval. + +## Counts and estimates + +The slice map has 434 unique paths, exactly matching its base-to-source Git diff. This ledger assigns every path once: 22 Phase 1 documentation/evidence paths (2 carried, 1 moved, 1 removed, 18 not carried), 10 build-facts paths handled in #1435, and 402 remaining assigned paths. Two Phase 2 policy/tool paths are already source-identical and require no port. Cross-phase test hunks are tracked above and do not double-count paths. + +| Phase | Assigned paths | New files | New source text lines | Existing paths | Existing source additions / removals, upper bound | +| ----- | -------------: | --------: | --------------------: | -------------: | ------------------------------------------------: | +| 2 | 274 | 241 | 79,907 | 33 | +1,443 / −559 | +| 3 | 74 | 72 | 20,438 | 2 | +1,189 / −14 | +| 4 | 54 | 45 | 14,985 | 9 | +675 / −2 | + +| Phase | Rust new lines / existing additions | Workflow, script and non-Rust test new lines / existing additions | +| ----- | ----------------------------------: | ----------------------------------------------------------------: | +| 2 | 72,530 / 284 | 4,089 / 534 | +| 3 | 18,457 / 0 | 1,415 / 1,189 | +| 4 | 14,811 / 330 | 0 / 0 | + +These are frozen-source estimates, not promised final diff sizes: new-file totals include mixed files before their assertion split; Rust includes Rust tests; tooling excludes Rust tests; binary fixtures have no text-line count. Existing changes use original base-to-source hunks excluding source-identical paths. Current-main adaptations, later integration designs and restored assertion hunks can change per-phase totals. Report actual counts in each PR. Counts exclude this new plan and the tracking-issue draft. + +## Separate design-approval gates + +Assignment approval authorizes the mapped port and dependency/test staging, not the following missing capabilities: + +1. **Real Fastly release integration.** Frozen `main.rs` has no build/equality/export command; bootstrap remains fail-closed; qualified Rust/JS tool enums remain uninhabited. Ordinary-CI equality exists in ignored tests, not a production release adapter. Design approval is required before implementing the release gate and real rebuild-check writer. Keep native reproduction measured, not gated. +2. **Complete SPDX.** `spdx::generate_from_completed` is a closed stub. Candidate projections cannot be presented as a complete SBOM. Owning complete composition inputs require an approved design. +3. **Index cohort check.** The carried v1 schema fixes four artifacts, which now matches the narrowed four-binary release; Phase 3 confirms the cohort against the published set before index construction. No placeholder signature bundles. +4. **Signed index and live publisher.** The frozen signed-companion types are closed; publication has an abstract API and fake tests but no live adapter. Approval precedes implementation. Preserve Phase 1 identities, names, predicates, release-ID mutation and recovery behavior. +5. **Public offline verification.** Frozen offline verification is internal, build-only and expects caller-enforced network denial. A public offline interface/trust boundary needs approval; manual commands or the private helper do not substitute for the requested capability. Offline build evidence does not prove current publication. +6. **Fork verifier trust.** Frozen consumer policy pins official repository IDs and signer URLs; online transport constructs official URLs. A trusted rehearsal policy/transport mechanism needs approval. Never infer authority from downloaded index contents or weaken production policy to accept the fork. + +No new trigger, production/qualified selection or maintainer authority is authorized. All `Qualified*` enums stay uninhabited. Ordinary-CI/rehearsal evidence retains `release_qualification:false`. Existing Phase 1 workflows/helpers remain until compatible replacements are rehearsed. No IAB tags, releases or settings changes. + +The local unpublished GNU masking commit `b91c7450a4f617d5ec879f612b295a532c7d7efa` is excluded and untouched. If separately approved later, its boundary/recipe coverage belongs in Phase 2 as its own reviewed commit. Its three new unmapped paths are `boundary/gnu_masks.rs`, `recipe/gnu/helper_masks.rs`, and `recipe/gnu/helper_masks/tests.rs` under the producer crate. No historical WIP test claim is reused as current verification. + +## Execution checkpoints + +Gate 1's [ordinary-release design](binary-attestations-phase2-fastly-integration.md) was approved on 2026-10-26, subject to an independently reviewed implementation plan. After merge, release CI runs the gate on every push to `main` and every `v*` tag: one additional Fastly build plus comparison, and a failed run (no release on a tag) on mismatch. A real mismatch is reported with bounded diagnostics; exclusions or weaker comparison are not authorized. + +- [x] Re-read source/map, independently investigate capture dependencies and carried build facts. +- [x] Assemble Phase 2 and Phase 3 scratch trees; run host check and strict clippy; correct prerequisite ownership. +- [x] Commit this assignment and [tracking-issue draft](binary-attestations-phases-2-4-tracking-issue.md) on Phase 2 (`26d69ce95`). +- [x] Port source by module, preserving Phase 1 hunks and recording each current-main adaptation; update per-path landing metadata in the slice map. +- [x] Adapt closed producer tables to the seven Phase 1 assets and current pins without opening qualifications or changing the index schema. +- [x] Run explicitly selected pure tests with a nonzero-selection guard; compile platform-specific branches in CI; keep process/ignored tests CI-only. +- [x] Complete current-source rehearsal after the supervision correction. Fresh `.20` passed at exact reviewed `5163241b947b1aa335987bd832b9205e4364e4bb`, with all 18 jobs, 30 immutable assets and full independent attestation/runtime verification. The [transcript](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6487647974) and integration plan retain exact-source attribution, review limits and prior capacity failures. This completion record is docs-only since that rehearsed source. +- [x] Obtain separate Fastly integration design approval and independently review the [concrete implementation plan](binary-attestations-phase2-fastly-integration-implementation.md) before implementing its missing gate/writer. Do not claim the intended Phase 2 capability is shipped by porting closed code. + +The following progress notes and per-path staged labels are historical snapshots of successive chunks. Current completion is recorded by the checkpoints above and the approved integration plan; an old snapshot is not an outstanding port task. + +### Initial Phase 2 port + +The first implementation unit ports `crates/trusted-server-cli/src/prebid_bundle.rs` byte-for-byte from the frozen source: 80 additions and five removals to an existing Rust file, including three pure filesystem tests. Release builds compile out the developer-checkout fallback and discover required resources from the runtime checkout; development fallback is preserved. Already-landed build-facts scripts and version output are unchanged. + +Verification selects each of the three new tests by its exact full name, requires exactly one listed test and one passing/non-ignored result, and requires a total of three executed tests. The default configuration passes; the release-build configuration also passes. CLI target-matched clippy, workspace Rust formatting, domain lint and independent review pass. CLI documentation generation completes with existing warnings. No process-spawning or ignored tests are selected. The larger producer port and seven-artifact adaptations remain pending; these initial checks do not establish phase-wide CI or release readiness. + +## Full path ledger + +### Producer foundation chunk + +This unit carries strict documents, Git object verification and process supervision: 18 original source/schema/license files remain byte-for-byte equal to the frozen source. Manifest dependencies, error variants and the binary entrypoint are staged for this subset. The entrypoint always fails with the original closed-execution diagnostic; no release command, policy/qualified selector or live process caller is exposed. Workspace membership is added and the source Rust floor is adapted to 1.98. + +Local all-target check and strict clippy pass. Exact-name selection executes 49 audited pure document/Git-object/environment tests with a nonzero count guard; process-spawning tests remain CI-only. Existing test/format jobs gain Linux producer tests and clippy without new triggers or permissions. Six existing workflow output-file redirections are quoted so actionlint and its shellcheck pass. These shared CI hunks land now; later candidate validation wiring remains Phase 3. + +The reviewed JSON Schema dependency requires three shared lock upgrades: `num-bigint` 0.4.6 → 0.4.8 (`fraction` requires ^0.4.8), `regex-automata` 0.4.14 → 0.4.18 (`fancy-regex` requires ^0.4.16), and `zmij` 1.0.21 → 1.0.23 (`jsonschema-value` requires ^1.0.23). Attempts to retain old versions failed those constraints. Hosted adapter/CLI tests must cover the shared-lock impact. No dependency version constraint is loosened. + +The user approved exact `json-schema.org` and `spdx.org` reference-host entries to preserve carried schemas unchanged. Justification comments and pure allow/subdomain/lookalike rejection coverage accompany them; list both entries for maintainer allowlist review in the PR. This prerequisite hunk lands in Phase 2; later consumer-related hunks of the shared domain file remain Phase 4. + +### Ledger + +The landing phase below is exclusive file ownership. Shared files receive later integration hunks as listed above. Phase 1 rows are retained, not re-ported. Phase 2 is #1449; Phase 3 is draft #1463, stacked on Phase 2. Phase 4 is not opened. Exact branches/bases and intended titles are recorded above. + +| Frozen-source path | Landing phase | State | +| -------------------------------------------------------------------------------------------------------------- | --------------- | ---------------------------------------------------------- | +| `docs/superpowers/plans/2026-10-13-attested-release-implementation.md` | 1 — removed | Removed from #1435; frozen at `122b78664` | +| `docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `docs/superpowers/specs/2026-01-15-attestation-design.md` | 1 — carried | Carried in #1435 | +| `docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md` | 1 — carried | Carried in #1435 | +| `docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `docs/superpowers/specs/schemas/release-index-v1.schema.json` | 1 — moved | Moved to `crates/trusted-server-release/schemas/` in #1449 | +| `scripts/release/js-composition.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-15-consumer-real-fixtures.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-15-js-two-builds.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-16-confined-cargo-candidate.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-16-fastly-runtime-gate.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-16-linux-cli-candidate.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-16-producer-normalized-manifests.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-19-apple-sdk-inputs.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-20-apple-cli-candidates.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-21-candidate-release-index.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-21-four-asset-candidate-join.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `scripts/release/qualification/2026-10-21-native-reproduction-schedule.md` | 1 — not carried | Not carried; frozen at `122b78664` | +| `Cargo.lock` | 2 | Staged Phase 2 dependency/support subset | +| `Cargo.toml` | 2 | Staged Phase 2 dependency/support subset | +| `crates/trusted-server-release/Cargo.toml` | 2 | Staged Phase 2 dependency/support subset | +| `crates/trusted-server-release/src/documents.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/documents/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/error.rs` | 2 | Staged Phase 2 dependency/support subset | +| `crates/trusted-server-release/src/git_objects.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/main.rs` | 2 | Staged Phase 2 dependency/support subset | +| `crates/trusted-server-release/src/policy.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/policy/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/policy/tools.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/preflight.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/preflight/child.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/preflight/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/closure.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/config.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/git.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/materialize.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/tests/closure.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/tests/layout.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/tests/objects.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/tests/process.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/tests/worktree.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/tree.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/source/worktree.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/tool_tree.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/tool_tree/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/commands.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/bootstrap.sh` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/release-cohort.json` | 2 | Restored with the four fixed rows; strict validation | +| `scripts/release/release-tools.json` | 2 | Already identical; retain, no port | +| `crates/trusted-server-release/src/boundary.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/command.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/command/selector_tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/daemon.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/docker.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/docker/host_path_tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/entropy.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/host_inputs.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/macos.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/probe.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/proof.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/roots.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/seccomp.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/selector_placement_tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/sibling_tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/survivors.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/tests/container.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/tests/linux.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/tests/macos.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/tests/modes.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/boundary/tests/unit.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/lifecycle_guard.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/environment.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/environment/selector_tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/pipes.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/redact.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/supervise.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/tests/fixture.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/tests/outcomes.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/tests/redaction.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/tests/supervision.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/process/tests/variables.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/boundary.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/control.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/qualification/SECCOMP-LICENSE` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/qualification/seccomp-default.json` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-adapter-fastly/build.rs` | 1 — carried | Removed in #1435 (`07c2bdf19`); not carried | +| `crates/trusted-server-adapter-fastly/src/main.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | +| `crates/trusted-server-cli/build.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | +| `crates/trusted-server-cli/src/prebid_bundle.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-cli/tests/release_build_facts.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | +| `crates/trusted-server-core/build.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | +| `crates/trusted-server-core/build_support/git_version.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | +| `crates/trusted-server-core/src/build_info.rs` | 1 — carried | Removed in #1435 (`3e6bb0f09`); not carried | +| `crates/trusted-server-core/src/constants.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | +| `crates/trusted-server-core/src/lib.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | +| `crates/trusted-server-core/tests/git_version_resolve.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | +| `crates/trusted-server-integration-tests/browser/helpers/infra.ts` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-integration-tests/tests/common/runtime.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-integration-tests/tests/frameworks/mod.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-integration-tests/tests/parity.rs` | 2 | Ported Phase 2 source or approved split | +| `docs/superpowers/specs/2026-09-25-git-version-header-design.md` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/Cargo.toml` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/README.md` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/build.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/build/bundle_set.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/build/inventory.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/build/prebuilt.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/.gitignore` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/.prettierignore` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/build-all.mjs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/build-module-options.mjs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/eslint.config.js` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/package.json` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/release-composition.mjs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/test/observe-npm.test.mjs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/test/release-composition.test.mjs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/lib/vitest.config.ts` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-js/tests/release_prebuilt.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/javascript.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/javascript/contributions.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/javascript/contributions/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/javascript/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/javascript/tests/adopt.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/archive.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/differential.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/index.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/source.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/cargo_vendor/writer.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/extract.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/extract/compatibility.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/extract/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/git.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/git/input.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/git/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/locked.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/manifest.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/npm_bundle.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/materials/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/preparation.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/preparation/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/preparation/tests/full.rs` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/build-js.sh` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/collect-js.mjs` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/observe-npm.mjs` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/tests/js-fixture.Dockerfile` | 2 | Deferred until a job consumes it | +| `scripts/release/tests/prepare-js-fixture.mjs` | 2 | Deferred until a job consumes it | +| `crates/trusted-server-adapter-cloudflare/build.sh` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/elf.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/elf/loader.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/macho.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/macho/signature.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/tests/builders.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/tests/builders/macho_data.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/tests/elf.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/tests/macho.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/tests/wasm.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/inspect/wasm.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/package.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/package/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/ambient.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/apple.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/apple/sdk.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/apple/toolchain.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/artifact.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/artifact/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/cargo_home.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/cargo_home/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/cargo_output.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/cargo_output/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/compare.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/completed.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/execute.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/fastly_tools.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/final_paths.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/fresh.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/gnu.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/gnu/image.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/gnu/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/identity.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/identity/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/native.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/paths.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/paths/fake.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/plan.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/remap.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/runtime.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/runtime/config.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/runtime/exchange.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/runtime/export.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/runtime/export/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/runtime/logs.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/runtime/viceroy.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/sources.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/startup.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/startup/accepted.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/startup/accepted/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/startup/lifecycle.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/startup/native.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/ambient.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/bind.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/candidate.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/candidate_entry.rs` | 3 | Pending | +| `crates/trusted-server-release/src/recipe/tests/candidate_index.rs` | 3 | Pending | +| `crates/trusted-server-release/src/recipe/tests/cli.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/cli_candidate.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/compare.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/complete.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/complete_darwin.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/darwin.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/fixture.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/full.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/full_macos.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/macos_candidate.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/macos_cli.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/native.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/plan.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/real_fixture.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/remap.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/runtime.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/sources.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs` | 3 | Pending | +| `crates/trusted-server-release/src/recipe/tests/tools.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/recipe/tools.rs` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/build.sh` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/tests/cargo-fixture.Dockerfile` | 2 | Deferred until a job consumes it | +| `scripts/release/tests/cli-builder.Dockerfile` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/tests/prepare-cargo-fixture.mjs` | 2 | Deferred until a job consumes it | +| `scripts/release/tests/prepare-cli-fixture.mjs` | 2 | Deferred until a job consumes it | +| `crates/trusted-server-release/schemas/SPDX-LICENSE` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/schemas/spdx-2.3.schema.json` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/capture.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/external.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/external/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/graph.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/graph/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/javascript.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/javascript/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/native.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/native/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/origin.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/origin/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/platform.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/platform/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/composition/tests.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/spdx.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/candidate.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/candidate/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/cargo.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/cargo/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/javascript.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/javascript/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/native.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/native/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/platform.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/platform/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/spdx/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/composition/README.md` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-host-metadata.json` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-messages.jsonl` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-target-filtered-metadata.json` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-tree.txt` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-messages.jsonl` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-metadata.json` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/src/collection.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/archive.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/producer.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/producer/strict_record.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/producer/strict_record/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/records.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/records/receipt.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/transfer.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/transfer/ordinary.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/transfer/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/workflow_jobs.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/workflow_jobs/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs` | 3 | Pending | +| `crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution/entry.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution/joins.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution/ordinary.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution/summary.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution/tests.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution/tests/golden.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution/tests/summary.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution/tests/transfer.rs` | 3 | Pending | +| `crates/trusted-server-release/src/distribution/transfer.rs` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/collection/README.md` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/collection/build-record.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/collection/composition.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json` | 3 | Pending | +| `crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json` | 3 | Pending | +| `crates/trusted-server-release/src/publication.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/api.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/incident.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/model.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/owner.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/role.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/tests.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/tests/drafts.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/tests/fake.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/tests/final_state.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/tests/inputs.rs` | 4 | Pending | +| `crates/trusted-server-release/src/publication/tests/reconciliation.rs` | 4 | Pending | +| `.github/actions/setup-integration-test-env/action.yml` | 3 | Pending | +| `.github/workflows/release-validation.mjs` | 3 | Pending | +| `.github/workflows/release-validation.test.mjs` | 3 | Pending | +| `.github/workflows/release-validation.yml` | 3 | Pending | +| `.github/workflows/test.yml` | 3 | Pending | +| `crates/trusted-server-release/src/workflow_contract.rs` | 3 | Pending | +| `crates/trusted-server-release/src/workflow_contract/tests.rs` | 3 | Pending | +| `scripts/release/qualification/approvals.json` | 3 | Pending | +| `scripts/release/qualification/hosted-workflow.proposal.yml` | 3 | Pending | +| `scripts/release/qualification/results.schema.json` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/tests/candidate-changes.sh` | 3 | Pending | +| `scripts/release/tests/candidate-changes.test.mjs` | 3 | Pending | +| `crates/trusted-server-cli/Cargo.toml` | 4 | Pending | +| `crates/trusted-server-cli/README.md` | 4 | Pending | +| `crates/trusted-server-cli/release-policy.json` | 2 | Already identical; retain, no port | +| `crates/trusted-server-cli/src/commands/dev/lint/domains.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/commands/mod.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/commands/release.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/lib.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/artifact.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/bounds.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/documents.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/environment.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/error.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/evidence.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/evidence/joins.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/gh.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/gh/input.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/github.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/mod.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/offline.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/online.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/policy.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/process.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/source_policy.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/documents/mod.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/documents/producer.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/gh/argv.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/gh/environment.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/gh/mod.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/mod.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/offline.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/online.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/verification/fixture.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/verification/mod.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/release/tests/verification/publication.rs` | 4 | Pending | +| `crates/trusted-server-cli/src/run.rs` | 4 | Pending | +| `crates/trusted-server-cli/tests/fixtures/release/release-index.json` | 3 | Pending | +| `crates/trusted-server-cli/tests/fixtures/release/upstream-formats.json` | 4 | Pending | +| `crates/trusted-server-cli/tests/release_cli.rs` | 4 | Pending | +| `.github/workflows/format.yml` | 2 | Staged Phase 2 dependency/support subset | +| `CHANGELOG.md` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-adapter-spin/src/app.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-adapter-spin/src/middleware.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-adapter-spin/tests/routes.rs` | 2 | Ported Phase 2 source or approved split | +| `crates/trusted-server-release/tests/fixtures/README.md` | 3 | Pending | +| `docs/.vitepress/config.mts` | 4 | Pending | +| `docs/guide/cli.md` | 4 | Pending | +| `docs/guide/edgezero.md` | 2 | Ported Phase 2 source or approved split | +| `docs/guide/error-reference.md` | 2 | Ported Phase 2 source or approved split | +| `docs/guide/getting-started.md` | 2 | Ported Phase 2 source or approved split | +| `docs/guide/release-verification.md` | 4 | Pending | +| `scripts/docs-proposal/hunks.mjs` | 2 | Ported Phase 2 source or approved split | +| `scripts/docs-proposal/hunks.test.mjs` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/qualification/README.md` | 2 | Ported Phase 2 source or approved split | +| `scripts/release/tests/fixtures/README.md` | 2 | Ported Phase 2 source or approved split | + +## Current port boundary at `13f63f020` + +The dependency-coupled producer graph is ported with the approved Phase 3 evidence-test splits. Its frozen diagnostic/preflight, package inspection, GNU measurement and loopback probe commands are present; no build/equality/export/publication command or inhabited qualified selector is introduced. The ordinary adapter remains a separate implementation group. Host all-target check, strict clippy, documentation and 229 audited pure tests pass; process and ignored tests remain CI-only. + +The tracked cohort is validated as exactly the four `FIXED_ARTIFACTS` rows, which remain the closed controlled-recipe/consumer/index destination contract; source-owned projection joins those exact names and order to the unchanged four-row consumer policy. A row outside that set, such as a former Axum row, is refused. An Axum distribution would need its own reviewed phase. + +The retained tools/consumer policy selects GitHub CLI 2.97.0 for its destination contract. The current operator guide uses 2.102.0 and the ordinary publisher records its installed version; carrying the destination pins does not enforce them in the ordinary path. Other selected tool versions match current source pins. + +The inspector recipe link is restored after the recipe port. A startup documentation link now names the private accepted-module type directly, without restoring a Phase 3-only re-export. Three workflow-contract command tests are held for their Phase 3 command. + +### JS preparation staging + +The frozen lifecycle observer keeps its esbuild admission (only its deletion pattern changed in `f243408ce`) and remains closed to esbuild 0.28.2 in the current npm lock; it accepts only 0.27.3. The full collector case is retained with a visible skipped reason and a guard against enabling its unsupported test control. This is a known controlled-recipe gap requiring a separate esbuild review. Ordinary release builds and the Gate 1 A/B adapter do not invoke that observer. + +The JS build-script port preserves ordinary fresh npm/private-output builds. Only explicitly selected prebuilt release inputs require both checked paths. Current Vite/Vitest versions and npm lock bytes are retained, checked-copy/process fixtures run in CI only, and local JS verification uses an audited pure selection. + +### Remaining integration at `f721ddbe0` + +The mapped new source files, staged JS preparation, residual prose and formatting hunks are ported. Shared manifests, command/workflow support and changelog remain staged for the approved Gate 1 integration; the ordinary runtime recorder, authenticated archive acquisition, atomic companion writer, signing/publication wiring and rehearsal are not complete. `c8ea09d6b` adds canonical source/lock/private-JS identities; it enables no command. Local checks now include 238 audited pure producer cases and 19 audited pure JS cases. The 39-test native JS fixture harness compiles and lists its tests; its subprocess cases remain CI-only. + +The release scripts pass shellcheck. The Cloudflare script retains exactly its four baseline SC1090/SC1091/SC2164 findings; its port changes only the locked-install explanation and equivalent flag order. No new warning is claimed resolved. + +### Ordinary adapter progress at `4ede699b4` + +The separately reviewed Gate 1 group now includes the source-pinned runtime recorder, fresh atomic observation/companion writers, explicit ordinary measurement/download-plan/comparison commands, and bounded authenticated archive acquisition. Comparison binds the direct upload outputs to service records and complete archive bytes, checks the recipe/lock identities and tool versions against its independent checkout, and writes only a real matched, unqualified result. The transport preserves raw service JSON, obtains the Rust binding before downloading any ZIP, and never forwards the API token to the signed blob URL or the planner process. + +Local checks pass for 258 audited pure producer cases with CI's backtrace setting and eight mocked transport cases. The native command process fixtures compile locally and remain CI-only. The current CI correction bounds owned diagnostic payloads separately from caller-enabled backtraces; production error handling is unchanged. + +At that revision, actual A/B build capture, workflow dependencies, the additive provenance subject, publication membership and the fork rehearsal were pending. No release gate was activated by those unwired commands/helpers. Qualified selectors remain uninhabited. + +### Ordinary A/B evidence and signing handoff + +At `7beaa5483166d073c88f12dda295ffd26bdbec97`, the [real hosted A/B comparison](https://github.com/IABTechLab/trusted-server/actions/runs/40456925305) passed with complete byte equality after the approved four-map compiler-path normalization. Both actual builds retained their debug sections and passed the physical-path scan. All four real artifact archives passed current-run upload binding and the narrow ZIP rules. This is CI build evidence, not a signed-publication rehearsal or controlled qualification. + +The additive signing handoff keeps binary/SPDX inputs unchanged, adds the real matched companion only for Fastly, checks its source/run/attempt and binary/SPDX digests, and signs it as a third provenance subject from the existing reusable signer. The companion travels with the signed Fastly artifact and its digest receipt. Publication validates that handoff before any mutation and adds only this companion, for 30 files and 29 checksum entries. Binary-only SPDX attestations and native input sets remain unchanged. Rehearsal `.10` at `3ccfe29715a073bd37010ea86037b9c8f89801c7` proved attestation compatibility and publication; its separate rerun-all proved current-attempt transfer and expected refusal of an existing immutable release. [Verification](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6438603951) and [recovery](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6439423827) are recorded independently of local tests. The subsequently merged head `770a425df4b28caaaef68dfd930bceabd9928046` has green CI after one lost-runner retry and passed fresh [rehearsal `.13` with full independent verification](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6441704601). Completion commit `2ed1b941a212aeb27e08f8d1e27cb8353f40f535` was docs-only after that exact rehearsed implementation. The later main merge is recorded separately in the compatibility document; `.13` does not attest its changed application inputs. + +The CI-only negative fixture invokes the real comparison executable on synthetic bound archives containing accurately hashed different fixture bytes. It requires the actual mismatch diagnostic, nonzero exit and absence of matched/later-stage sentinels within fixed time/output bounds. Contract tests require the real sign job to need the comparison and forbid status functions in the sign and publication job conditions, without a release-path bypass, new trigger or altered-source tag. Process tests remain hosted-only. diff --git a/docs/superpowers/plans/binary-attestations-slice-map.json b/docs/superpowers/plans/binary-attestations-slice-map.json new file mode 100644 index 0000000000..3ae008cea9 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-slice-map.json @@ -0,0 +1,2234 @@ +{ + "base": "f9281917a7260b723f5f22175911c6d2147ef650", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "total": 434, + "notes": [ + "All file paths in this map refer to the frozen #1262 source commit 122b78664b1235c717e9208fd200f9a9b0c335df (https://github.com/IABTechLab/trusted-server/tree/122b78664b1235c717e9208fd200f9a9b0c335df), not to this branch; many of them, including crates/trusted-server-release, the qualification reports, release-tools.json and release-policy.json, are not present in PR #1435.", + "File ownership is a planning map, not safe cherry-pick boundaries: main.rs, Cargo manifests/lock, shared fixtures and workflow YAML require staged hunks.", + "At 122b786 the linked evidence closure included the source-modified legacy spec, migration addendum, two integration plans, 13 dated evidence reports and JS composition narrative; only the legacy spec is carried in #1435; baseline axum-dev.md need not port.", + "Commit citations identify introducing or major shaping commits, not complete exact-replay commit sets.", + "Phase 1 interim producers scripts/release/phase1-build.sh and phase1.mjs are replaced by slices 2.5 (recipes/inspection) and 2.6 (composition/SPDX); the Phase 1 publisher (scripts/release/publish.mjs, formerly inline in release.yml and still keyed by that name below) is replaced by slice 2.8. Slice 2.3 immutable build facts are partly adopted now; CLI source-resource cleanup remains deferred.", + "Landing assignment approved on 2026-10-26; path_phases gives exclusive ownership while groups retain original slice provenance. See binary-attestations-phases-2-4.md for staged test/integration hunks, closed gates and closure evidence. Null pull_request means the draft has not been opened." + ], + "groups": { + "phase-1-contract-and-linked-evidence": { + "commits": [ + "91c190710a821fad73835a5f32c3628ffff20636", + "24a8a7180a32a30e0438d9f5b77697c058c6e9c3", + "484d2c0845be35f006cd8ecafb54dd1fc41e9a9b", + "58d2e7a8b6f81ef4e6b7546321ddc2e51846e415", + "6d0efda7210983021ab3c9efe8a0a3190e9eb81b" + ], + "files": [ + "docs/superpowers/plans/2026-10-13-attested-release-implementation.md", + "docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md", + "docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md", + "docs/superpowers/specs/2026-01-15-attestation-design.md", + "docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md", + "docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md", + "docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md", + "docs/superpowers/specs/schemas/release-index-v1.schema.json", + "scripts/release/js-composition.md", + "scripts/release/qualification/2026-10-15-consumer-real-fixtures.md", + "scripts/release/qualification/2026-10-15-js-two-builds.md", + "scripts/release/qualification/2026-10-16-confined-cargo-candidate.md", + "scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md", + "scripts/release/qualification/2026-10-16-fastly-runtime-gate.md", + "scripts/release/qualification/2026-10-16-linux-cli-candidate.md", + "scripts/release/qualification/2026-10-16-producer-normalized-manifests.md", + "scripts/release/qualification/2026-10-19-apple-sdk-inputs.md", + "scripts/release/qualification/2026-10-20-apple-cli-candidates.md", + "scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md", + "scripts/release/qualification/2026-10-21-candidate-release-index.md", + "scripts/release/qualification/2026-10-21-four-asset-candidate-join.md", + "scripts/release/qualification/2026-10-21-native-reproduction-schedule.md" + ], + "phase": "1", + "state": "Reviewed documentation and historical evidence only" + }, + "phase-2a-strict-documents-policy-preflight-source": { + "commits": [ + "1cfca1357d0eee7158a4e6049444fef46807c564", + "90a87c35ac73c1f1a137740c48df5af41cc7df90", + "917620d2e383471e804d19205f235412ebede83d", + "8a68537344d6c9e1dbd7c8dea3731ea971346376", + "67aaa3fc3d8ba21011472be60e500b884f590742" + ], + "files": [ + "Cargo.lock", + "Cargo.toml", + "crates/trusted-server-release/Cargo.toml", + "crates/trusted-server-release/src/documents.rs", + "crates/trusted-server-release/src/documents/tests.rs", + "crates/trusted-server-release/src/error.rs", + "crates/trusted-server-release/src/git_objects.rs", + "crates/trusted-server-release/src/main.rs", + "crates/trusted-server-release/src/policy.rs", + "crates/trusted-server-release/src/policy/tests.rs", + "crates/trusted-server-release/src/policy/tools.rs", + "crates/trusted-server-release/src/preflight.rs", + "crates/trusted-server-release/src/preflight/child.rs", + "crates/trusted-server-release/src/preflight/tests.rs", + "crates/trusted-server-release/src/source.rs", + "crates/trusted-server-release/src/source/closure.rs", + "crates/trusted-server-release/src/source/config.rs", + "crates/trusted-server-release/src/source/git.rs", + "crates/trusted-server-release/src/source/materialize.rs", + "crates/trusted-server-release/src/source/tests.rs", + "crates/trusted-server-release/src/source/tests/closure.rs", + "crates/trusted-server-release/src/source/tests/layout.rs", + "crates/trusted-server-release/src/source/tests/objects.rs", + "crates/trusted-server-release/src/source/tests/process.rs", + "crates/trusted-server-release/src/source/tests/worktree.rs", + "crates/trusted-server-release/src/source/tree.rs", + "crates/trusted-server-release/src/source/worktree.rs", + "crates/trusted-server-release/src/tool_tree.rs", + "crates/trusted-server-release/src/tool_tree/tests.rs", + "crates/trusted-server-release/tests/commands.rs", + "crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin", + "scripts/release/bootstrap.sh", + "scripts/release/release-cohort.json", + "scripts/release/release-tools.json" + ], + "phase": "2.1", + "state": "Closed; no triggers or production selection" + }, + "phase-2b-process-and-native-sandbox": { + "commits": [ + "f407fe446fcb4daa431bae2ee1834e1ec16664fc", + "39fbf7cf2f6c7c3621e192d1897920250e5f9dab", + "60fafa7175f26685edd88c8336e958e8e709b000", + "d4408d2e015f44bd5e9c0641a9e9f1acc966907f" + ], + "files": [ + "crates/trusted-server-release/src/boundary.rs", + "crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs", + "crates/trusted-server-release/src/boundary/command.rs", + "crates/trusted-server-release/src/boundary/command/selector_tests.rs", + "crates/trusted-server-release/src/boundary/daemon.rs", + "crates/trusted-server-release/src/boundary/docker.rs", + "crates/trusted-server-release/src/boundary/docker/host_path_tests.rs", + "crates/trusted-server-release/src/boundary/entropy.rs", + "crates/trusted-server-release/src/boundary/host_inputs.rs", + "crates/trusted-server-release/src/boundary/macos.rs", + "crates/trusted-server-release/src/boundary/probe.rs", + "crates/trusted-server-release/src/boundary/proof.rs", + "crates/trusted-server-release/src/boundary/roots.rs", + "crates/trusted-server-release/src/boundary/seccomp.rs", + "crates/trusted-server-release/src/boundary/selector_placement_tests.rs", + "crates/trusted-server-release/src/boundary/sibling_tests.rs", + "crates/trusted-server-release/src/boundary/survivors.rs", + "crates/trusted-server-release/src/boundary/tests.rs", + "crates/trusted-server-release/src/boundary/tests/container.rs", + "crates/trusted-server-release/src/boundary/tests/linux.rs", + "crates/trusted-server-release/src/boundary/tests/macos.rs", + "crates/trusted-server-release/src/boundary/tests/modes.rs", + "crates/trusted-server-release/src/boundary/tests/unit.rs", + "crates/trusted-server-release/src/lifecycle_guard.rs", + "crates/trusted-server-release/src/process.rs", + "crates/trusted-server-release/src/process/environment.rs", + "crates/trusted-server-release/src/process/environment/selector_tests.rs", + "crates/trusted-server-release/src/process/pipes.rs", + "crates/trusted-server-release/src/process/redact.rs", + "crates/trusted-server-release/src/process/supervise.rs", + "crates/trusted-server-release/src/process/tests.rs", + "crates/trusted-server-release/src/process/tests/fixture.rs", + "crates/trusted-server-release/src/process/tests/outcomes.rs", + "crates/trusted-server-release/src/process/tests/redaction.rs", + "crates/trusted-server-release/src/process/tests/supervision.rs", + "crates/trusted-server-release/src/process/tests/variables.rs", + "crates/trusted-server-release/tests/boundary.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock", + "crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/control.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs", + "scripts/release/qualification/SECCOMP-LICENSE", + "scripts/release/qualification/seccomp-default.json" + ], + "phase": "2.2", + "state": "Closed; no triggers or production selection" + }, + "phase-2c-immutable-build-facts": { + "commits": [ + "bfb697404551a28217af8906024506095bcd9559", + "6a07ccc03278af1a3e07b30e25a1cd46efb2f57b" + ], + "files": [ + "crates/trusted-server-adapter-fastly/build.rs", + "crates/trusted-server-adapter-fastly/src/main.rs", + "crates/trusted-server-cli/build.rs", + "crates/trusted-server-cli/src/prebid_bundle.rs", + "crates/trusted-server-cli/tests/release_build_facts.rs", + "crates/trusted-server-core/build.rs", + "crates/trusted-server-core/build_support/git_version.rs", + "crates/trusted-server-core/src/build_info.rs", + "crates/trusted-server-core/src/constants.rs", + "crates/trusted-server-core/src/lib.rs", + "crates/trusted-server-core/tests/git_version_resolve.rs", + "crates/trusted-server-integration-tests/browser/helpers/infra.ts", + "crates/trusted-server-integration-tests/tests/common/runtime.rs", + "crates/trusted-server-integration-tests/tests/frameworks/mod.rs", + "crates/trusted-server-integration-tests/tests/parity.rs", + "docs/superpowers/specs/2026-09-25-git-version-header-design.md" + ], + "phase": "2.3", + "state": "Closed; no triggers or production selection" + }, + "phase-2d-js-inventory-composition-materials-two-builds": { + "commits": [ + "e1606fa3da0af4deb4d75b26a11d6677704d747c", + "eeb68f9188b4609bd3b8f7890a6fe51cbc26a57d", + "2edd46ce63b5684a5a0fce976a60bb9794e1c6cf", + "bf3057d60f99a92693c0836305129ba68962afd5", + "0c67be66ee57584c9107f3cb6ea796c77d8a20cb" + ], + "files": [ + "crates/trusted-server-js/Cargo.toml", + "crates/trusted-server-js/README.md", + "crates/trusted-server-js/build.rs", + "crates/trusted-server-js/build/bundle_set.rs", + "crates/trusted-server-js/build/inventory.rs", + "crates/trusted-server-js/build/prebuilt.rs", + "crates/trusted-server-js/lib/.gitignore", + "crates/trusted-server-js/lib/.prettierignore", + "crates/trusted-server-js/lib/build-all.mjs", + "crates/trusted-server-js/lib/build-module-options.mjs", + "crates/trusted-server-js/lib/eslint.config.js", + "crates/trusted-server-js/lib/package.json", + "crates/trusted-server-js/lib/release-composition.mjs", + "crates/trusted-server-js/lib/test/observe-npm.test.mjs", + "crates/trusted-server-js/lib/test/release-composition.test.mjs", + "crates/trusted-server-js/lib/vitest.config.ts", + "crates/trusted-server-js/tests/release_prebuilt.rs", + "crates/trusted-server-release/src/javascript.rs", + "crates/trusted-server-release/src/javascript/contributions.rs", + "crates/trusted-server-release/src/javascript/contributions/tests.rs", + "crates/trusted-server-release/src/javascript/tests.rs", + "crates/trusted-server-release/src/javascript/tests/adopt.rs", + "crates/trusted-server-release/src/materials.rs", + "crates/trusted-server-release/src/materials/archive.rs", + "crates/trusted-server-release/src/materials/cargo_vendor.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/differential.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/index.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/source.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/writer.rs", + "crates/trusted-server-release/src/materials/extract.rs", + "crates/trusted-server-release/src/materials/extract/compatibility.rs", + "crates/trusted-server-release/src/materials/extract/tests.rs", + "crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs", + "crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs", + "crates/trusted-server-release/src/materials/git.rs", + "crates/trusted-server-release/src/materials/git/input.rs", + "crates/trusted-server-release/src/materials/git/tests.rs", + "crates/trusted-server-release/src/materials/locked.rs", + "crates/trusted-server-release/src/materials/manifest.rs", + "crates/trusted-server-release/src/materials/npm_bundle.rs", + "crates/trusted-server-release/src/materials/tests.rs", + "crates/trusted-server-release/src/preparation.rs", + "crates/trusted-server-release/src/preparation/tests.rs", + "crates/trusted-server-release/src/preparation/tests/full.rs", + "scripts/release/build-js.sh", + "scripts/release/collect-js.mjs", + "scripts/release/observe-npm.mjs", + "scripts/release/tests/js-fixture.Dockerfile", + "scripts/release/tests/prepare-js-fixture.mjs" + ], + "phase": "2.4", + "state": "Closed; no triggers or production selection" + }, + "phase-2e-recipes-inspection-manual-package": { + "commits": [ + "a7ae687547409540ed748fc89024b2c9dd84a5b0", + "03fa8e5a622fc7cf86d81195ccdd2f40df50ccfc", + "d472aaa50fb3d532ca997c6822a8c0b8907386de", + "4cc5700e834c3238f99a81b3a567edb634fecfe1", + "10167c6017b725d0a4d5ad0a0c8e6a96fd4aa807", + "c78185b8d699f56d843a01c8812890f38e4a55f6", + "58fa27d3e8b45987fe51d36a80a7d6db74b3e072", + "9999c39584d5b2731bb83a6dae995aad59cc9e38" + ], + "files": [ + "crates/trusted-server-adapter-cloudflare/build.sh", + "crates/trusted-server-release/src/inspect.rs", + "crates/trusted-server-release/src/inspect/elf.rs", + "crates/trusted-server-release/src/inspect/elf/loader.rs", + "crates/trusted-server-release/src/inspect/macho.rs", + "crates/trusted-server-release/src/inspect/macho/signature.rs", + "crates/trusted-server-release/src/inspect/tests.rs", + "crates/trusted-server-release/src/inspect/tests/builders.rs", + "crates/trusted-server-release/src/inspect/tests/builders/macho_data.rs", + "crates/trusted-server-release/src/inspect/tests/elf.rs", + "crates/trusted-server-release/src/inspect/tests/macho.rs", + "crates/trusted-server-release/src/inspect/tests/wasm.rs", + "crates/trusted-server-release/src/inspect/wasm.rs", + "crates/trusted-server-release/src/package.rs", + "crates/trusted-server-release/src/package/tests.rs", + "crates/trusted-server-release/src/recipe.rs", + "crates/trusted-server-release/src/recipe/ambient.rs", + "crates/trusted-server-release/src/recipe/apple.rs", + "crates/trusted-server-release/src/recipe/apple/sdk.rs", + "crates/trusted-server-release/src/recipe/apple/toolchain.rs", + "crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs", + "crates/trusted-server-release/src/recipe/artifact.rs", + "crates/trusted-server-release/src/recipe/artifact/tests.rs", + "crates/trusted-server-release/src/recipe/cargo_home.rs", + "crates/trusted-server-release/src/recipe/cargo_home/tests.rs", + "crates/trusted-server-release/src/recipe/cargo_output.rs", + "crates/trusted-server-release/src/recipe/cargo_output/tests.rs", + "crates/trusted-server-release/src/recipe/compare.rs", + "crates/trusted-server-release/src/recipe/completed.rs", + "crates/trusted-server-release/src/recipe/execute.rs", + "crates/trusted-server-release/src/recipe/fastly_tools.rs", + "crates/trusted-server-release/src/recipe/final_paths.rs", + "crates/trusted-server-release/src/recipe/fresh.rs", + "crates/trusted-server-release/src/recipe/gnu.rs", + "crates/trusted-server-release/src/recipe/gnu/image.rs", + "crates/trusted-server-release/src/recipe/gnu/tests.rs", + "crates/trusted-server-release/src/recipe/identity.rs", + "crates/trusted-server-release/src/recipe/identity/tests.rs", + "crates/trusted-server-release/src/recipe/native.rs", + "crates/trusted-server-release/src/recipe/paths.rs", + "crates/trusted-server-release/src/recipe/paths/fake.rs", + "crates/trusted-server-release/src/recipe/plan.rs", + "crates/trusted-server-release/src/recipe/remap.rs", + "crates/trusted-server-release/src/recipe/runtime.rs", + "crates/trusted-server-release/src/recipe/runtime/config.rs", + "crates/trusted-server-release/src/recipe/runtime/exchange.rs", + "crates/trusted-server-release/src/recipe/runtime/export.rs", + "crates/trusted-server-release/src/recipe/runtime/export/tests.rs", + "crates/trusted-server-release/src/recipe/runtime/logs.rs", + "crates/trusted-server-release/src/recipe/runtime/viceroy.rs", + "crates/trusted-server-release/src/recipe/sources.rs", + "crates/trusted-server-release/src/recipe/startup.rs", + "crates/trusted-server-release/src/recipe/startup/accepted.rs", + "crates/trusted-server-release/src/recipe/startup/accepted/tests.rs", + "crates/trusted-server-release/src/recipe/startup/lifecycle.rs", + "crates/trusted-server-release/src/recipe/startup/native.rs", + "crates/trusted-server-release/src/recipe/tests.rs", + "crates/trusted-server-release/src/recipe/tests/ambient.rs", + "crates/trusted-server-release/src/recipe/tests/bind.rs", + "crates/trusted-server-release/src/recipe/tests/candidate.rs", + "crates/trusted-server-release/src/recipe/tests/candidate_entry.rs", + "crates/trusted-server-release/src/recipe/tests/candidate_index.rs", + "crates/trusted-server-release/src/recipe/tests/cli.rs", + "crates/trusted-server-release/src/recipe/tests/cli_candidate.rs", + "crates/trusted-server-release/src/recipe/tests/compare.rs", + "crates/trusted-server-release/src/recipe/tests/complete.rs", + "crates/trusted-server-release/src/recipe/tests/complete_darwin.rs", + "crates/trusted-server-release/src/recipe/tests/darwin.rs", + "crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs", + "crates/trusted-server-release/src/recipe/tests/fixture.rs", + "crates/trusted-server-release/src/recipe/tests/full.rs", + "crates/trusted-server-release/src/recipe/tests/full_macos.rs", + "crates/trusted-server-release/src/recipe/tests/macos_candidate.rs", + "crates/trusted-server-release/src/recipe/tests/macos_cli.rs", + "crates/trusted-server-release/src/recipe/tests/native.rs", + "crates/trusted-server-release/src/recipe/tests/plan.rs", + "crates/trusted-server-release/src/recipe/tests/real_fixture.rs", + "crates/trusted-server-release/src/recipe/tests/remap.rs", + "crates/trusted-server-release/src/recipe/tests/runtime.rs", + "crates/trusted-server-release/src/recipe/tests/sources.rs", + "crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs", + "crates/trusted-server-release/src/recipe/tests/tools.rs", + "crates/trusted-server-release/src/recipe/tools.rs", + "scripts/release/build.sh", + "scripts/release/tests/cargo-fixture.Dockerfile", + "scripts/release/tests/cli-builder.Dockerfile", + "scripts/release/tests/prepare-cargo-fixture.mjs", + "scripts/release/tests/prepare-cli-fixture.mjs" + ], + "phase": "2.5", + "state": "Closed; no triggers or production selection" + }, + "phase-2f-composition-spdx": { + "commits": [ + "4176b81f2056f8ff639da48875bb4f9876a82582", + "6bc3dcf678c87ee3d3dc48090b34c32c81b72988", + "775696b28a45e4349ffed970c6d31452219cf0e1", + "1719cf15fd58fcf029f6b4ae5003215802bef405", + "416b43f43c5d7e69c08bca32753240abded8523c", + "5a98c18ada25863a896b93f379125e51497e83f8" + ], + "files": [ + "crates/trusted-server-release/schemas/SPDX-LICENSE", + "crates/trusted-server-release/schemas/spdx-2.3.schema.json", + "crates/trusted-server-release/src/composition.rs", + "crates/trusted-server-release/src/composition/capture.rs", + "crates/trusted-server-release/src/composition/external.rs", + "crates/trusted-server-release/src/composition/external/tests.rs", + "crates/trusted-server-release/src/composition/graph.rs", + "crates/trusted-server-release/src/composition/graph/tests.rs", + "crates/trusted-server-release/src/composition/javascript.rs", + "crates/trusted-server-release/src/composition/javascript/tests.rs", + "crates/trusted-server-release/src/composition/native.rs", + "crates/trusted-server-release/src/composition/native/tests.rs", + "crates/trusted-server-release/src/composition/origin.rs", + "crates/trusted-server-release/src/composition/origin/tests.rs", + "crates/trusted-server-release/src/composition/platform.rs", + "crates/trusted-server-release/src/composition/platform/tests.rs", + "crates/trusted-server-release/src/composition/tests.rs", + "crates/trusted-server-release/src/spdx.rs", + "crates/trusted-server-release/src/spdx/candidate.rs", + "crates/trusted-server-release/src/spdx/candidate/tests.rs", + "crates/trusted-server-release/src/spdx/cargo.rs", + "crates/trusted-server-release/src/spdx/cargo/tests.rs", + "crates/trusted-server-release/src/spdx/javascript.rs", + "crates/trusted-server-release/src/spdx/javascript/tests.rs", + "crates/trusted-server-release/src/spdx/native.rs", + "crates/trusted-server-release/src/spdx/native/tests.rs", + "crates/trusted-server-release/src/spdx/platform.rs", + "crates/trusted-server-release/src/spdx/platform/tests.rs", + "crates/trusted-server-release/src/spdx/tests.rs", + "crates/trusted-server-release/tests/fixtures/composition/README.md", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-host-metadata.json", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-messages.jsonl", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-target-filtered-metadata.json", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-tree.txt", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-messages.jsonl", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-metadata.json" + ], + "phase": "2.6", + "state": "Closed; no triggers or production selection" + }, + "phase-2g-collection-workflow-jobs-candidate-index": { + "commits": [ + "6c065adf29b5be142d5c448c430259ab28987a55", + "0f43d4a81f9ea1339a254a4020d302f2eb7012ce", + "6ffebbd0872d9a8d52d0525dcb2ee55959a710fc", + "060172fb62856ea2b94ead6c196a21bb5e75ac1d", + "99a8c5803e22a717afd03a86b367d816bd1302f9" + ], + "files": [ + "crates/trusted-server-release/src/collection.rs", + "crates/trusted-server-release/src/collection/archive.rs", + "crates/trusted-server-release/src/collection/producer.rs", + "crates/trusted-server-release/src/collection/producer/strict_record.rs", + "crates/trusted-server-release/src/collection/producer/strict_record/tests.rs", + "crates/trusted-server-release/src/collection/records.rs", + "crates/trusted-server-release/src/collection/records/receipt.rs", + "crates/trusted-server-release/src/collection/tests.rs", + "crates/trusted-server-release/src/collection/transfer.rs", + "crates/trusted-server-release/src/collection/transfer/ordinary.rs", + "crates/trusted-server-release/src/collection/transfer/tests.rs", + "crates/trusted-server-release/src/collection/workflow_jobs.rs", + "crates/trusted-server-release/src/collection/workflow_jobs/tests.rs", + "crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs", + "crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs", + "crates/trusted-server-release/src/distribution.rs", + "crates/trusted-server-release/src/distribution/entry.rs", + "crates/trusted-server-release/src/distribution/joins.rs", + "crates/trusted-server-release/src/distribution/ordinary.rs", + "crates/trusted-server-release/src/distribution/summary.rs", + "crates/trusted-server-release/src/distribution/tests.rs", + "crates/trusted-server-release/src/distribution/tests/golden.rs", + "crates/trusted-server-release/src/distribution/tests/summary.rs", + "crates/trusted-server-release/src/distribution/tests/transfer.rs", + "crates/trusted-server-release/src/distribution/transfer.rs", + "crates/trusted-server-release/tests/fixtures/collection/README.md", + "crates/trusted-server-release/tests/fixtures/collection/build-record.json", + "crates/trusted-server-release/tests/fixtures/collection/composition.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json" + ], + "phase": "2.7", + "state": "Closed; no triggers or production selection" + }, + "phase-2h-publication-owner": { + "commits": ["9bd8be2c20a2e188fbdddef7a838b63545279e82"], + "files": [ + "crates/trusted-server-release/src/publication.rs", + "crates/trusted-server-release/src/publication/api.rs", + "crates/trusted-server-release/src/publication/incident.rs", + "crates/trusted-server-release/src/publication/model.rs", + "crates/trusted-server-release/src/publication/owner.rs", + "crates/trusted-server-release/src/publication/role.rs", + "crates/trusted-server-release/src/publication/tests.rs", + "crates/trusted-server-release/src/publication/tests/drafts.rs", + "crates/trusted-server-release/src/publication/tests/fake.rs", + "crates/trusted-server-release/src/publication/tests/final_state.rs", + "crates/trusted-server-release/src/publication/tests/inputs.rs", + "crates/trusted-server-release/src/publication/tests/reconciliation.rs" + ], + "phase": "2.8", + "state": "Closed; no triggers or production selection" + }, + "phase-2i-candidate-ci-same-commit-validation": { + "commits": [ + "2b642b5091298b9278f4e64a2342fd57d23d1aae", + "3e3e30996714f3691319e4cb6ca96d4fbb3240e3", + "b4c8e0c0fded27f02930edc6de990f17a63f5820", + "4483e8256dc52c89e615f1388daa873324a871f5", + "073704cc7f65793767b129a5a330447e02926c7b" + ], + "files": [ + ".github/actions/setup-integration-test-env/action.yml", + ".github/workflows/release-validation.mjs", + ".github/workflows/release-validation.test.mjs", + ".github/workflows/release-validation.yml", + ".github/workflows/test.yml", + "crates/trusted-server-release/src/workflow_contract.rs", + "crates/trusted-server-release/src/workflow_contract/tests.rs", + "scripts/release/qualification/approvals.json", + "scripts/release/qualification/hosted-workflow.proposal.yml", + "scripts/release/qualification/results.schema.json", + "scripts/release/tests/candidate-changes.sh", + "scripts/release/tests/candidate-changes.test.mjs" + ], + "phase": "2.9", + "state": "Closed; no triggers or production selection" + }, + "phase-2j-consumer-cli": { + "commits": [ + "7450b37805131d307d402df089d7a585104dd543", + "da3c9ce6682915aef2ae48d02d8d343181e05702", + "4eebe62138606636e095aa975b7dd37a4fa899ca", + "ac07047ed2b4f1bbe0e1f2e4aac9f845e70b7c7c", + "d774ee47c274257fa0a088280f7df37ed530020c" + ], + "files": [ + "crates/trusted-server-cli/Cargo.toml", + "crates/trusted-server-cli/README.md", + "crates/trusted-server-cli/release-policy.json", + "crates/trusted-server-cli/src/commands/dev/lint/domains.rs", + "crates/trusted-server-cli/src/commands/mod.rs", + "crates/trusted-server-cli/src/commands/release.rs", + "crates/trusted-server-cli/src/lib.rs", + "crates/trusted-server-cli/src/release/artifact.rs", + "crates/trusted-server-cli/src/release/bounds.rs", + "crates/trusted-server-cli/src/release/documents.rs", + "crates/trusted-server-cli/src/release/environment.rs", + "crates/trusted-server-cli/src/release/error.rs", + "crates/trusted-server-cli/src/release/evidence.rs", + "crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs", + "crates/trusted-server-cli/src/release/evidence/joins.rs", + "crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs", + "crates/trusted-server-cli/src/release/gh.rs", + "crates/trusted-server-cli/src/release/gh/input.rs", + "crates/trusted-server-cli/src/release/github.rs", + "crates/trusted-server-cli/src/release/mod.rs", + "crates/trusted-server-cli/src/release/offline.rs", + "crates/trusted-server-cli/src/release/online.rs", + "crates/trusted-server-cli/src/release/policy.rs", + "crates/trusted-server-cli/src/release/process.rs", + "crates/trusted-server-cli/src/release/source_policy.rs", + "crates/trusted-server-cli/src/release/tests/documents/mod.rs", + "crates/trusted-server-cli/src/release/tests/documents/producer.rs", + "crates/trusted-server-cli/src/release/tests/gh/argv.rs", + "crates/trusted-server-cli/src/release/tests/gh/environment.rs", + "crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs", + "crates/trusted-server-cli/src/release/tests/gh/mod.rs", + "crates/trusted-server-cli/src/release/tests/mod.rs", + "crates/trusted-server-cli/src/release/tests/offline.rs", + "crates/trusted-server-cli/src/release/tests/online.rs", + "crates/trusted-server-cli/src/release/tests/verification/fixture.rs", + "crates/trusted-server-cli/src/release/tests/verification/mod.rs", + "crates/trusted-server-cli/src/release/tests/verification/publication.rs", + "crates/trusted-server-cli/src/run.rs", + "crates/trusted-server-cli/tests/fixtures/release/release-index.json", + "crates/trusted-server-cli/tests/fixtures/release/upstream-formats.json", + "crates/trusted-server-cli/tests/release_cli.rs" + ], + "phase": "2.10", + "state": "Closed; no triggers or production selection" + }, + "phase-2k-docs-and-format-misc": { + "commits": [ + "8510fde38b494ad7c637f0981772709a7a9b0ad5", + "6a07ccc03278af1a3e07b30e25a1cd46efb2f57b", + "58fa27d3e8b45987fe51d36a80a7d6db74b3e072" + ], + "files": [ + ".github/workflows/format.yml", + "CHANGELOG.md", + "crates/trusted-server-adapter-spin/src/app.rs", + "crates/trusted-server-adapter-spin/src/middleware.rs", + "crates/trusted-server-adapter-spin/tests/routes.rs", + "crates/trusted-server-release/tests/fixtures/README.md", + "docs/.vitepress/config.mts", + "docs/guide/cli.md", + "docs/guide/edgezero.md", + "docs/guide/error-reference.md", + "docs/guide/getting-started.md", + "docs/guide/release-verification.md", + "scripts/docs-proposal/hunks.mjs", + "scripts/docs-proposal/hunks.test.mjs", + "scripts/release/qualification/README.md", + "scripts/release/tests/fixtures/README.md" + ], + "phase": "2.11", + "state": "Closed; no triggers or production selection" + } + }, + "phase1_code_reuse": [ + { + "path": "crates/trusted-server-cli/src/commands/dev/lint/domains.rs", + "source_commit": "6d0efda7210983021ab3c9efe8a0a3190e9eb81b", + "subset": "SLSA and SPDX exact reference-host entries. Other source scanner changes and tests stay in the consumer slice." + }, + { + "source_path": "crates/trusted-server-release/src/recipe/native.rs", + "source_commit": "10167c6017b725d0a4d5ad0a0c8e6a96fd4aa807", + "destination": "scripts/release/phase1.mjs", + "subset": "Translate the small planted-operator-value byte scanner; no producer crate or qualification machinery is imported." + } + ], + "axum_amendment": { + "status": "Moot after the Phase 1 narrowing to four binaries (18 published files with the Phase 2 rebuild check); retained as history for a future Axum amendment. Frozen v1 contracts stay unchanged", + "executables": 4, + "phase1_release_files": 17, + "requires_reviewed_schema_and_policy_amendment": true, + "full_graph_note": "The frozen full graph has 4 release-wide files plus 7 per executable: 32 for 4 executables, or 53 for 7. Phase 1 publishes only 4 per executable plus SHA256SUMS: 17, or 18 with the Phase 2 rebuild check.", + "paths": { + "crates/trusted-server-release/src/policy.rs": "Update for Axum: ReleaseTarget/ALL and producer FIXED_ARTIFACTS encode four identities; extend targets, server product and exact seven-entry cohort.", + "crates/trusted-server-cli/src/release/policy.rs": "Update for Axum: Consumer FIXED_ARTIFACTS/validate_artifacts encode four identities; review seven-entry policy and new capability forms.", + "crates/trusted-server-cli/src/release/bounds.rs": "Update for Axum: COHORT_EXECUTABLES is four; review all derived bounds.", + "crates/trusted-server-cli/release-policy.json": "Update for Axum: Four records and cohort_executables=4; leave the frozen 122b786 bytes unchanged until reviewed consumer-policy amendment.", + "scripts/release/release-cohort.json": "Update for Axum: Phase 1 deliberately adds three Axum records; original four remain unchanged and no Phase 1 reader is added.", + "scripts/release/release-tools.json": "Update for Axum: Existing recipe/tool records cover four artifacts; keep the frozen 122b786 bytes unchanged and review musl/ARM/Axum tool identities later.", + "scripts/release/qualification/approvals.json": "Update for Axum: Qualification keys cover the original four products and environments.", + "scripts/release/qualification/results.schema.json": "Update for Axum: Asset/environment/tool/platform enumerations require Axum, musl and ARM review.", + "docs/superpowers/specs/schemas/release-index-v1.schema.json": "Update for Axum: Frozen min/max4, identity-presence clauses and target/capability/compatibility enums require reviewed amendment; do not edit the frozen 122b786 schema.", + "crates/trusted-server-release/src/distribution.rs": "Update for Axum: cohort_order and index validation enforce exact FIXED_ARTIFACTS membership.", + "crates/trusted-server-release/src/distribution/ordinary.rs": "Update for Axum: DESCRIPTOR_SLOTS[4] and four-job collection require three additional entries.", + "crates/trusted-server-release/src/publication/model.rs": "Update for Axum: INDEX_ARTIFACTS=4 and derived 32-asset full graph; seven-artifact full graph would have 53, not Phase 1 count 29.", + ".github/workflows/test.yml": "Update for Axum: Selection/candidate-index dependencies, descriptor variables and four-job fixture invocation need seven producers.", + "crates/trusted-server-release/src/policy/tests.rs": "Update for Axum: Exact four-name assertions and every-native-is-CLI assumptions.", + "crates/trusted-server-release/src/collection/tests.rs": "Update for Axum: Exact four-artifact expectation.", + "crates/trusted-server-release/src/distribution/tests.rs": "Update for Axum: Four-entry schema joins and duplicate-entry assertions.", + "crates/trusted-server-release/src/distribution/tests/transfer.rs": "Update for Axum: Fewer-than-four rejection cases.", + "crates/trusted-server-release/src/distribution/tests/golden.rs": "Update for Axum: Four-artifact embedded candidate fixture set.", + "crates/trusted-server-release/src/recipe/tests/candidate_entry.rs": "Update for Axum: Explicit four-artifact rebuild outcome list.", + "crates/trusted-server-release/src/recipe/tests/candidate_index.rs": "Update for Axum: Four-artifact candidate-index assembly fixtures.", + "crates/trusted-server-release/src/publication/tests/inputs.rs": "Update for Axum: 32-asset acceptance/33-asset rejection reflect the full frozen graph.", + "crates/trusted-server-cli/src/release/tests/documents/mod.rs": "Update for Axum: Explicit artifacts.len()==4 assertion.", + "crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs": "Update for Axum: Embedded four-artifact fixture set.", + "crates/trusted-server-cli/tests/fixtures/release/release-index.json": "Update for Axum: Exact original four identities.", + "crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json": "Update for Axum: Exact original four identities.", + "crates/trusted-server-release/src/inspect.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/inspect/elf.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/execute.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/plan.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/startup.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/startup/accepted.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/startup/native.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/composition/external.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/composition/native.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/composition/platform.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/spdx/platform.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index." + } + }, + "phase1_interim_producers": { + "scripts/release/phase1-build.sh": { + "replaced_by": ["2.5"] + }, + "scripts/release/phase1.mjs": { + "replaced_by": ["2.5", "2.6"] + }, + "scripts/release/publish.mjs": { + "replaced_by": ["2.8"] + } + }, + "landing_phases": { + "1": { + "branch": "feature/binary-attestations-phase1", + "pull_request": 1435 + }, + "2": { + "branch": "feature/binary-attestations-phase2", + "base": "feature/binary-attestations-phase1", + "pull_request": 1449 + }, + "3": { + "branch": "feature/binary-attestations-phase3", + "base": "feature/binary-attestations-phase2", + "pull_request": 1463 + }, + "4": { + "branch": "feature/binary-attestations-phase4", + "base": "feature/binary-attestations-phase3", + "pull_request": null + } + }, + "path_phases": { + "docs/superpowers/plans/2026-10-13-attested-release-implementation.md": 1, + "docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md": 1, + "docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md": 1, + "docs/superpowers/specs/2026-01-15-attestation-design.md": 1, + "docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md": 1, + "docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md": 1, + "docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md": 1, + "docs/superpowers/specs/schemas/release-index-v1.schema.json": 1, + "scripts/release/js-composition.md": 1, + "scripts/release/qualification/2026-10-15-consumer-real-fixtures.md": 1, + "scripts/release/qualification/2026-10-15-js-two-builds.md": 1, + "scripts/release/qualification/2026-10-16-confined-cargo-candidate.md": 1, + "scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md": 1, + "scripts/release/qualification/2026-10-16-fastly-runtime-gate.md": 1, + "scripts/release/qualification/2026-10-16-linux-cli-candidate.md": 1, + "scripts/release/qualification/2026-10-16-producer-normalized-manifests.md": 1, + "scripts/release/qualification/2026-10-19-apple-sdk-inputs.md": 1, + "scripts/release/qualification/2026-10-20-apple-cli-candidates.md": 1, + "scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md": 1, + "scripts/release/qualification/2026-10-21-candidate-release-index.md": 1, + "scripts/release/qualification/2026-10-21-four-asset-candidate-join.md": 1, + "scripts/release/qualification/2026-10-21-native-reproduction-schedule.md": 1, + "Cargo.lock": 2, + "Cargo.toml": 2, + "crates/trusted-server-release/Cargo.toml": 2, + "crates/trusted-server-release/src/documents.rs": 2, + "crates/trusted-server-release/src/documents/tests.rs": 2, + "crates/trusted-server-release/src/error.rs": 2, + "crates/trusted-server-release/src/git_objects.rs": 2, + "crates/trusted-server-release/src/main.rs": 2, + "crates/trusted-server-release/src/policy.rs": 2, + "crates/trusted-server-release/src/policy/tests.rs": 2, + "crates/trusted-server-release/src/policy/tools.rs": 2, + "crates/trusted-server-release/src/preflight.rs": 2, + "crates/trusted-server-release/src/preflight/child.rs": 2, + "crates/trusted-server-release/src/preflight/tests.rs": 2, + "crates/trusted-server-release/src/source.rs": 2, + "crates/trusted-server-release/src/source/closure.rs": 2, + "crates/trusted-server-release/src/source/config.rs": 2, + "crates/trusted-server-release/src/source/git.rs": 2, + "crates/trusted-server-release/src/source/materialize.rs": 2, + "crates/trusted-server-release/src/source/tests.rs": 2, + "crates/trusted-server-release/src/source/tests/closure.rs": 2, + "crates/trusted-server-release/src/source/tests/layout.rs": 2, + "crates/trusted-server-release/src/source/tests/objects.rs": 2, + "crates/trusted-server-release/src/source/tests/process.rs": 2, + "crates/trusted-server-release/src/source/tests/worktree.rs": 2, + "crates/trusted-server-release/src/source/tree.rs": 2, + "crates/trusted-server-release/src/source/worktree.rs": 2, + "crates/trusted-server-release/src/tool_tree.rs": 2, + "crates/trusted-server-release/src/tool_tree/tests.rs": 2, + "crates/trusted-server-release/tests/commands.rs": 2, + "crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin": 2, + "scripts/release/bootstrap.sh": 2, + "scripts/release/release-cohort.json": 2, + "scripts/release/release-tools.json": 2, + "crates/trusted-server-release/src/boundary.rs": 2, + "crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs": 2, + "crates/trusted-server-release/src/boundary/command.rs": 2, + "crates/trusted-server-release/src/boundary/command/selector_tests.rs": 2, + "crates/trusted-server-release/src/boundary/daemon.rs": 2, + "crates/trusted-server-release/src/boundary/docker.rs": 2, + "crates/trusted-server-release/src/boundary/docker/host_path_tests.rs": 2, + "crates/trusted-server-release/src/boundary/entropy.rs": 2, + "crates/trusted-server-release/src/boundary/host_inputs.rs": 2, + "crates/trusted-server-release/src/boundary/macos.rs": 2, + "crates/trusted-server-release/src/boundary/probe.rs": 2, + "crates/trusted-server-release/src/boundary/proof.rs": 2, + "crates/trusted-server-release/src/boundary/roots.rs": 2, + "crates/trusted-server-release/src/boundary/seccomp.rs": 2, + "crates/trusted-server-release/src/boundary/selector_placement_tests.rs": 2, + "crates/trusted-server-release/src/boundary/sibling_tests.rs": 2, + "crates/trusted-server-release/src/boundary/survivors.rs": 2, + "crates/trusted-server-release/src/boundary/tests.rs": 2, + "crates/trusted-server-release/src/boundary/tests/container.rs": 2, + "crates/trusted-server-release/src/boundary/tests/linux.rs": 2, + "crates/trusted-server-release/src/boundary/tests/macos.rs": 2, + "crates/trusted-server-release/src/boundary/tests/modes.rs": 2, + "crates/trusted-server-release/src/boundary/tests/unit.rs": 2, + "crates/trusted-server-release/src/lifecycle_guard.rs": 2, + "crates/trusted-server-release/src/process.rs": 2, + "crates/trusted-server-release/src/process/environment.rs": 2, + "crates/trusted-server-release/src/process/environment/selector_tests.rs": 2, + "crates/trusted-server-release/src/process/pipes.rs": 2, + "crates/trusted-server-release/src/process/redact.rs": 2, + "crates/trusted-server-release/src/process/supervise.rs": 2, + "crates/trusted-server-release/src/process/tests.rs": 2, + "crates/trusted-server-release/src/process/tests/fixture.rs": 2, + "crates/trusted-server-release/src/process/tests/outcomes.rs": 2, + "crates/trusted-server-release/src/process/tests/redaction.rs": 2, + "crates/trusted-server-release/src/process/tests/supervision.rs": 2, + "crates/trusted-server-release/src/process/tests/variables.rs": 2, + "crates/trusted-server-release/tests/boundary.rs": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/control.rs": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs": 2, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs": 2, + "scripts/release/qualification/SECCOMP-LICENSE": 2, + "scripts/release/qualification/seccomp-default.json": 2, + "crates/trusted-server-adapter-fastly/build.rs": 1, + "crates/trusted-server-adapter-fastly/src/main.rs": 1, + "crates/trusted-server-cli/build.rs": 1, + "crates/trusted-server-cli/src/prebid_bundle.rs": 2, + "crates/trusted-server-cli/tests/release_build_facts.rs": 1, + "crates/trusted-server-core/build.rs": 1, + "crates/trusted-server-core/build_support/git_version.rs": 1, + "crates/trusted-server-core/src/build_info.rs": 1, + "crates/trusted-server-core/src/constants.rs": 1, + "crates/trusted-server-core/src/lib.rs": 1, + "crates/trusted-server-core/tests/git_version_resolve.rs": 1, + "crates/trusted-server-integration-tests/browser/helpers/infra.ts": 2, + "crates/trusted-server-integration-tests/tests/common/runtime.rs": 2, + "crates/trusted-server-integration-tests/tests/frameworks/mod.rs": 2, + "crates/trusted-server-integration-tests/tests/parity.rs": 2, + "docs/superpowers/specs/2026-09-25-git-version-header-design.md": 2, + "crates/trusted-server-js/Cargo.toml": 2, + "crates/trusted-server-js/README.md": 2, + "crates/trusted-server-js/build.rs": 2, + "crates/trusted-server-js/build/bundle_set.rs": 2, + "crates/trusted-server-js/build/inventory.rs": 2, + "crates/trusted-server-js/build/prebuilt.rs": 2, + "crates/trusted-server-js/lib/.gitignore": 2, + "crates/trusted-server-js/lib/.prettierignore": 2, + "crates/trusted-server-js/lib/build-all.mjs": 2, + "crates/trusted-server-js/lib/build-module-options.mjs": 2, + "crates/trusted-server-js/lib/eslint.config.js": 2, + "crates/trusted-server-js/lib/package.json": 2, + "crates/trusted-server-js/lib/release-composition.mjs": 2, + "crates/trusted-server-js/lib/test/observe-npm.test.mjs": 2, + "crates/trusted-server-js/lib/test/release-composition.test.mjs": 2, + "crates/trusted-server-js/lib/vitest.config.ts": 2, + "crates/trusted-server-js/tests/release_prebuilt.rs": 2, + "crates/trusted-server-release/src/javascript.rs": 2, + "crates/trusted-server-release/src/javascript/contributions.rs": 2, + "crates/trusted-server-release/src/javascript/contributions/tests.rs": 2, + "crates/trusted-server-release/src/javascript/tests.rs": 2, + "crates/trusted-server-release/src/javascript/tests/adopt.rs": 2, + "crates/trusted-server-release/src/materials.rs": 2, + "crates/trusted-server-release/src/materials/archive.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/differential.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/index.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/source.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/tests.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs": 2, + "crates/trusted-server-release/src/materials/cargo_vendor/writer.rs": 2, + "crates/trusted-server-release/src/materials/extract.rs": 2, + "crates/trusted-server-release/src/materials/extract/compatibility.rs": 2, + "crates/trusted-server-release/src/materials/extract/tests.rs": 2, + "crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs": 2, + "crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs": 2, + "crates/trusted-server-release/src/materials/git.rs": 2, + "crates/trusted-server-release/src/materials/git/input.rs": 2, + "crates/trusted-server-release/src/materials/git/tests.rs": 2, + "crates/trusted-server-release/src/materials/locked.rs": 2, + "crates/trusted-server-release/src/materials/manifest.rs": 2, + "crates/trusted-server-release/src/materials/npm_bundle.rs": 2, + "crates/trusted-server-release/src/materials/tests.rs": 2, + "crates/trusted-server-release/src/preparation.rs": 2, + "crates/trusted-server-release/src/preparation/tests.rs": 2, + "crates/trusted-server-release/src/preparation/tests/full.rs": 2, + "scripts/release/build-js.sh": 2, + "scripts/release/collect-js.mjs": 2, + "scripts/release/observe-npm.mjs": 2, + "scripts/release/tests/js-fixture.Dockerfile": 2, + "scripts/release/tests/prepare-js-fixture.mjs": 2, + "crates/trusted-server-adapter-cloudflare/build.sh": 2, + "crates/trusted-server-release/src/inspect.rs": 2, + "crates/trusted-server-release/src/inspect/elf.rs": 2, + "crates/trusted-server-release/src/inspect/elf/loader.rs": 2, + "crates/trusted-server-release/src/inspect/macho.rs": 2, + "crates/trusted-server-release/src/inspect/macho/signature.rs": 2, + "crates/trusted-server-release/src/inspect/tests.rs": 2, + "crates/trusted-server-release/src/inspect/tests/builders.rs": 2, + "crates/trusted-server-release/src/inspect/tests/builders/macho_data.rs": 2, + "crates/trusted-server-release/src/inspect/tests/elf.rs": 2, + "crates/trusted-server-release/src/inspect/tests/macho.rs": 2, + "crates/trusted-server-release/src/inspect/tests/wasm.rs": 2, + "crates/trusted-server-release/src/inspect/wasm.rs": 2, + "crates/trusted-server-release/src/package.rs": 2, + "crates/trusted-server-release/src/package/tests.rs": 2, + "crates/trusted-server-release/src/recipe.rs": 2, + "crates/trusted-server-release/src/recipe/ambient.rs": 2, + "crates/trusted-server-release/src/recipe/apple.rs": 2, + "crates/trusted-server-release/src/recipe/apple/sdk.rs": 2, + "crates/trusted-server-release/src/recipe/apple/toolchain.rs": 2, + "crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs": 2, + "crates/trusted-server-release/src/recipe/artifact.rs": 2, + "crates/trusted-server-release/src/recipe/artifact/tests.rs": 2, + "crates/trusted-server-release/src/recipe/cargo_home.rs": 2, + "crates/trusted-server-release/src/recipe/cargo_home/tests.rs": 2, + "crates/trusted-server-release/src/recipe/cargo_output.rs": 2, + "crates/trusted-server-release/src/recipe/cargo_output/tests.rs": 2, + "crates/trusted-server-release/src/recipe/compare.rs": 2, + "crates/trusted-server-release/src/recipe/completed.rs": 2, + "crates/trusted-server-release/src/recipe/execute.rs": 2, + "crates/trusted-server-release/src/recipe/fastly_tools.rs": 2, + "crates/trusted-server-release/src/recipe/final_paths.rs": 2, + "crates/trusted-server-release/src/recipe/fresh.rs": 2, + "crates/trusted-server-release/src/recipe/gnu.rs": 2, + "crates/trusted-server-release/src/recipe/gnu/image.rs": 2, + "crates/trusted-server-release/src/recipe/gnu/tests.rs": 2, + "crates/trusted-server-release/src/recipe/identity.rs": 2, + "crates/trusted-server-release/src/recipe/identity/tests.rs": 2, + "crates/trusted-server-release/src/recipe/native.rs": 2, + "crates/trusted-server-release/src/recipe/paths.rs": 2, + "crates/trusted-server-release/src/recipe/paths/fake.rs": 2, + "crates/trusted-server-release/src/recipe/plan.rs": 2, + "crates/trusted-server-release/src/recipe/remap.rs": 2, + "crates/trusted-server-release/src/recipe/runtime.rs": 2, + "crates/trusted-server-release/src/recipe/runtime/config.rs": 2, + "crates/trusted-server-release/src/recipe/runtime/exchange.rs": 2, + "crates/trusted-server-release/src/recipe/runtime/export.rs": 2, + "crates/trusted-server-release/src/recipe/runtime/export/tests.rs": 2, + "crates/trusted-server-release/src/recipe/runtime/logs.rs": 2, + "crates/trusted-server-release/src/recipe/runtime/viceroy.rs": 2, + "crates/trusted-server-release/src/recipe/sources.rs": 2, + "crates/trusted-server-release/src/recipe/startup.rs": 2, + "crates/trusted-server-release/src/recipe/startup/accepted.rs": 2, + "crates/trusted-server-release/src/recipe/startup/accepted/tests.rs": 2, + "crates/trusted-server-release/src/recipe/startup/lifecycle.rs": 2, + "crates/trusted-server-release/src/recipe/startup/native.rs": 2, + "crates/trusted-server-release/src/recipe/tests.rs": 2, + "crates/trusted-server-release/src/recipe/tests/ambient.rs": 2, + "crates/trusted-server-release/src/recipe/tests/bind.rs": 2, + "crates/trusted-server-release/src/recipe/tests/candidate.rs": 2, + "crates/trusted-server-release/src/recipe/tests/candidate_entry.rs": 3, + "crates/trusted-server-release/src/recipe/tests/candidate_index.rs": 3, + "crates/trusted-server-release/src/recipe/tests/cli.rs": 2, + "crates/trusted-server-release/src/recipe/tests/cli_candidate.rs": 2, + "crates/trusted-server-release/src/recipe/tests/compare.rs": 2, + "crates/trusted-server-release/src/recipe/tests/complete.rs": 2, + "crates/trusted-server-release/src/recipe/tests/complete_darwin.rs": 2, + "crates/trusted-server-release/src/recipe/tests/darwin.rs": 2, + "crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs": 2, + "crates/trusted-server-release/src/recipe/tests/fixture.rs": 2, + "crates/trusted-server-release/src/recipe/tests/full.rs": 2, + "crates/trusted-server-release/src/recipe/tests/full_macos.rs": 2, + "crates/trusted-server-release/src/recipe/tests/macos_candidate.rs": 2, + "crates/trusted-server-release/src/recipe/tests/macos_cli.rs": 2, + "crates/trusted-server-release/src/recipe/tests/native.rs": 2, + "crates/trusted-server-release/src/recipe/tests/plan.rs": 2, + "crates/trusted-server-release/src/recipe/tests/real_fixture.rs": 2, + "crates/trusted-server-release/src/recipe/tests/remap.rs": 2, + "crates/trusted-server-release/src/recipe/tests/runtime.rs": 2, + "crates/trusted-server-release/src/recipe/tests/sources.rs": 2, + "crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs": 3, + "crates/trusted-server-release/src/recipe/tests/tools.rs": 2, + "crates/trusted-server-release/src/recipe/tools.rs": 2, + "scripts/release/build.sh": 2, + "scripts/release/tests/cargo-fixture.Dockerfile": 2, + "scripts/release/tests/cli-builder.Dockerfile": 2, + "scripts/release/tests/prepare-cargo-fixture.mjs": 2, + "scripts/release/tests/prepare-cli-fixture.mjs": 2, + "crates/trusted-server-release/schemas/SPDX-LICENSE": 2, + "crates/trusted-server-release/schemas/spdx-2.3.schema.json": 2, + "crates/trusted-server-release/src/composition.rs": 2, + "crates/trusted-server-release/src/composition/capture.rs": 2, + "crates/trusted-server-release/src/composition/external.rs": 2, + "crates/trusted-server-release/src/composition/external/tests.rs": 2, + "crates/trusted-server-release/src/composition/graph.rs": 2, + "crates/trusted-server-release/src/composition/graph/tests.rs": 2, + "crates/trusted-server-release/src/composition/javascript.rs": 2, + "crates/trusted-server-release/src/composition/javascript/tests.rs": 2, + "crates/trusted-server-release/src/composition/native.rs": 2, + "crates/trusted-server-release/src/composition/native/tests.rs": 2, + "crates/trusted-server-release/src/composition/origin.rs": 2, + "crates/trusted-server-release/src/composition/origin/tests.rs": 2, + "crates/trusted-server-release/src/composition/platform.rs": 2, + "crates/trusted-server-release/src/composition/platform/tests.rs": 2, + "crates/trusted-server-release/src/composition/tests.rs": 2, + "crates/trusted-server-release/src/spdx.rs": 3, + "crates/trusted-server-release/src/spdx/candidate.rs": 3, + "crates/trusted-server-release/src/spdx/candidate/tests.rs": 3, + "crates/trusted-server-release/src/spdx/cargo.rs": 3, + "crates/trusted-server-release/src/spdx/cargo/tests.rs": 3, + "crates/trusted-server-release/src/spdx/javascript.rs": 3, + "crates/trusted-server-release/src/spdx/javascript/tests.rs": 3, + "crates/trusted-server-release/src/spdx/native.rs": 3, + "crates/trusted-server-release/src/spdx/native/tests.rs": 3, + "crates/trusted-server-release/src/spdx/platform.rs": 3, + "crates/trusted-server-release/src/spdx/platform/tests.rs": 3, + "crates/trusted-server-release/src/spdx/tests.rs": 3, + "crates/trusted-server-release/tests/fixtures/composition/README.md": 2, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-host-metadata.json": 2, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-messages.jsonl": 2, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-target-filtered-metadata.json": 2, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-tree.txt": 2, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-messages.jsonl": 2, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-metadata.json": 2, + "crates/trusted-server-release/src/collection.rs": 3, + "crates/trusted-server-release/src/collection/archive.rs": 3, + "crates/trusted-server-release/src/collection/producer.rs": 3, + "crates/trusted-server-release/src/collection/producer/strict_record.rs": 3, + "crates/trusted-server-release/src/collection/producer/strict_record/tests.rs": 3, + "crates/trusted-server-release/src/collection/records.rs": 3, + "crates/trusted-server-release/src/collection/records/receipt.rs": 3, + "crates/trusted-server-release/src/collection/tests.rs": 3, + "crates/trusted-server-release/src/collection/transfer.rs": 3, + "crates/trusted-server-release/src/collection/transfer/ordinary.rs": 3, + "crates/trusted-server-release/src/collection/transfer/tests.rs": 3, + "crates/trusted-server-release/src/collection/workflow_jobs.rs": 3, + "crates/trusted-server-release/src/collection/workflow_jobs/tests.rs": 3, + "crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs": 3, + "crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs": 3, + "crates/trusted-server-release/src/distribution.rs": 3, + "crates/trusted-server-release/src/distribution/entry.rs": 3, + "crates/trusted-server-release/src/distribution/joins.rs": 3, + "crates/trusted-server-release/src/distribution/ordinary.rs": 3, + "crates/trusted-server-release/src/distribution/summary.rs": 3, + "crates/trusted-server-release/src/distribution/tests.rs": 3, + "crates/trusted-server-release/src/distribution/tests/golden.rs": 3, + "crates/trusted-server-release/src/distribution/tests/summary.rs": 3, + "crates/trusted-server-release/src/distribution/tests/transfer.rs": 3, + "crates/trusted-server-release/src/distribution/transfer.rs": 3, + "crates/trusted-server-release/tests/fixtures/collection/README.md": 3, + "crates/trusted-server-release/tests/fixtures/collection/build-record.json": 3, + "crates/trusted-server-release/tests/fixtures/collection/composition.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json": 3, + "crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json": 3, + "crates/trusted-server-release/src/publication.rs": 4, + "crates/trusted-server-release/src/publication/api.rs": 4, + "crates/trusted-server-release/src/publication/incident.rs": 4, + "crates/trusted-server-release/src/publication/model.rs": 4, + "crates/trusted-server-release/src/publication/owner.rs": 4, + "crates/trusted-server-release/src/publication/role.rs": 4, + "crates/trusted-server-release/src/publication/tests.rs": 4, + "crates/trusted-server-release/src/publication/tests/drafts.rs": 4, + "crates/trusted-server-release/src/publication/tests/fake.rs": 4, + "crates/trusted-server-release/src/publication/tests/final_state.rs": 4, + "crates/trusted-server-release/src/publication/tests/inputs.rs": 4, + "crates/trusted-server-release/src/publication/tests/reconciliation.rs": 4, + ".github/actions/setup-integration-test-env/action.yml": 3, + ".github/workflows/release-validation.mjs": 3, + ".github/workflows/release-validation.test.mjs": 3, + ".github/workflows/release-validation.yml": 3, + ".github/workflows/test.yml": 3, + "crates/trusted-server-release/src/workflow_contract.rs": 3, + "crates/trusted-server-release/src/workflow_contract/tests.rs": 3, + "scripts/release/qualification/approvals.json": 3, + "scripts/release/qualification/hosted-workflow.proposal.yml": 3, + "scripts/release/qualification/results.schema.json": 2, + "scripts/release/tests/candidate-changes.sh": 3, + "scripts/release/tests/candidate-changes.test.mjs": 3, + "crates/trusted-server-cli/Cargo.toml": 4, + "crates/trusted-server-cli/README.md": 4, + "crates/trusted-server-cli/release-policy.json": 2, + "crates/trusted-server-cli/src/commands/dev/lint/domains.rs": 4, + "crates/trusted-server-cli/src/commands/mod.rs": 4, + "crates/trusted-server-cli/src/commands/release.rs": 4, + "crates/trusted-server-cli/src/lib.rs": 4, + "crates/trusted-server-cli/src/release/artifact.rs": 4, + "crates/trusted-server-cli/src/release/bounds.rs": 4, + "crates/trusted-server-cli/src/release/documents.rs": 4, + "crates/trusted-server-cli/src/release/environment.rs": 4, + "crates/trusted-server-cli/src/release/error.rs": 4, + "crates/trusted-server-cli/src/release/evidence.rs": 4, + "crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs": 4, + "crates/trusted-server-cli/src/release/evidence/joins.rs": 4, + "crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs": 4, + "crates/trusted-server-cli/src/release/gh.rs": 4, + "crates/trusted-server-cli/src/release/gh/input.rs": 4, + "crates/trusted-server-cli/src/release/github.rs": 4, + "crates/trusted-server-cli/src/release/mod.rs": 4, + "crates/trusted-server-cli/src/release/offline.rs": 4, + "crates/trusted-server-cli/src/release/online.rs": 4, + "crates/trusted-server-cli/src/release/policy.rs": 4, + "crates/trusted-server-cli/src/release/process.rs": 4, + "crates/trusted-server-cli/src/release/source_policy.rs": 4, + "crates/trusted-server-cli/src/release/tests/documents/mod.rs": 4, + "crates/trusted-server-cli/src/release/tests/documents/producer.rs": 4, + "crates/trusted-server-cli/src/release/tests/gh/argv.rs": 4, + "crates/trusted-server-cli/src/release/tests/gh/environment.rs": 4, + "crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs": 4, + "crates/trusted-server-cli/src/release/tests/gh/mod.rs": 4, + "crates/trusted-server-cli/src/release/tests/mod.rs": 4, + "crates/trusted-server-cli/src/release/tests/offline.rs": 4, + "crates/trusted-server-cli/src/release/tests/online.rs": 4, + "crates/trusted-server-cli/src/release/tests/verification/fixture.rs": 4, + "crates/trusted-server-cli/src/release/tests/verification/mod.rs": 4, + "crates/trusted-server-cli/src/release/tests/verification/publication.rs": 4, + "crates/trusted-server-cli/src/run.rs": 4, + "crates/trusted-server-cli/tests/fixtures/release/release-index.json": 3, + "crates/trusted-server-cli/tests/fixtures/release/upstream-formats.json": 4, + "crates/trusted-server-cli/tests/release_cli.rs": 4, + ".github/workflows/format.yml": 2, + "CHANGELOG.md": 2, + "crates/trusted-server-adapter-spin/src/app.rs": 2, + "crates/trusted-server-adapter-spin/src/middleware.rs": 2, + "crates/trusted-server-adapter-spin/tests/routes.rs": 2, + "crates/trusted-server-release/tests/fixtures/README.md": 3, + "docs/.vitepress/config.mts": 4, + "docs/guide/cli.md": 4, + "docs/guide/edgezero.md": 2, + "docs/guide/error-reference.md": 2, + "docs/guide/getting-started.md": 2, + "docs/guide/release-verification.md": 4, + "scripts/docs-proposal/hunks.mjs": 2, + "scripts/docs-proposal/hunks.test.mjs": 2, + "scripts/release/qualification/README.md": 2, + "scripts/release/tests/fixtures/README.md": 2 + }, + "landing_status": { + "Cargo.lock": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Staged Phase 2 dependency/support subset" + }, + "Cargo.toml": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Staged Phase 2 dependency/support subset" + }, + "crates/trusted-server-release/Cargo.toml": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Staged Phase 2 dependency/support subset" + }, + "crates/trusted-server-release/src/documents.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/documents/tests.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/error.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Staged Phase 2 dependency/support subset" + }, + "crates/trusted-server-release/src/git_objects.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/main.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Staged Phase 2 dependency/support subset" + }, + "crates/trusted-server-release/src/policy.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/policy/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/policy/tools.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/preflight.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/preflight/child.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/preflight/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/closure.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/config.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/git.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/materialize.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/tests/closure.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/tests/layout.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/tests/objects.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/tests/process.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/tests/worktree.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/tree.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/source/worktree.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/tool_tree.rs": { + "commit": "4447b68a5cc935d7ea7aaf9032d2b657537f1e98", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/tool_tree/tests.rs": { + "commit": "4447b68a5cc935d7ea7aaf9032d2b657537f1e98", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/commands.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/bootstrap.sh": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/command.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/command/selector_tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/daemon.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/docker.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/docker/host_path_tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/entropy.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/host_inputs.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/macos.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/probe.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/proof.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/roots.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/seccomp.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/selector_placement_tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/sibling_tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/survivors.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/tests/container.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/tests/linux.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/tests/macos.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/tests/modes.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/boundary/tests/unit.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/lifecycle_guard.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split", + "review_followup": { + "commit": "927109fa13413af1579500ed1256d467c6890370", + "reason": "Replace destructive post-reap group confirmation with bounded signal-zero observation; preserve initial pinned SIGKILL, reaping and real errno handling." + } + }, + "crates/trusted-server-release/src/process/environment.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process/environment/selector_tests.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process/pipes.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process/redact.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process/supervise.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split", + "review_followup": { + "commit": "927109fa13413af1579500ed1256d467c6890370", + "reason": "Replace destructive post-reap group confirmation with bounded signal-zero observation; preserve initial pinned SIGKILL, reaping and real errno handling." + } + }, + "crates/trusted-server-release/src/process/tests.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process/tests/fixture.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process/tests/outcomes.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process/tests/redaction.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process/tests/supervision.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/process/tests/variables.rs": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/boundary.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/control.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/qualification/SECCOMP-LICENSE": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/qualification/seccomp-default.json": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-cli/src/prebid_bundle.rs": { + "commit": "4ee86da6ca6eebe4afed185029272ff9150a7b74", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-integration-tests/browser/helpers/infra.ts": { + "commit": "d1c1d6967c4a6813fe6d3b6c0168fb37a6618360", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-integration-tests/tests/common/runtime.rs": { + "commit": "d1c1d6967c4a6813fe6d3b6c0168fb37a6618360", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-integration-tests/tests/frameworks/mod.rs": { + "commit": "d1c1d6967c4a6813fe6d3b6c0168fb37a6618360", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-integration-tests/tests/parity.rs": { + "commit": "d1c1d6967c4a6813fe6d3b6c0168fb37a6618360", + "state": "Ported Phase 2 source or approved split" + }, + "docs/superpowers/specs/2026-09-25-git-version-header-design.md": { + "commit": "d1c1d6967c4a6813fe6d3b6c0168fb37a6618360", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/Cargo.toml": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/README.md": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/build.rs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/build/bundle_set.rs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/build/inventory.rs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/build/prebuilt.rs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/.gitignore": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/.prettierignore": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/build-all.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/build-module-options.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/eslint.config.js": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/package.json": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/release-composition.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/test/observe-npm.test.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/test/release-composition.test.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/lib/vitest.config.ts": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-js/tests/release_prebuilt.rs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/javascript.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/javascript/contributions.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/javascript/contributions/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/javascript/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/javascript/tests/adopt.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/archive.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/differential.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/index.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/source.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/cargo_vendor/writer.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/extract.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/extract/compatibility.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/extract/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/git.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/git/input.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/git/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/locked.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/manifest.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/npm_bundle.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/materials/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/preparation.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/preparation/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/preparation/tests/full.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/build-js.sh": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/collect-js.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/observe-npm.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/tests/js-fixture.Dockerfile": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/tests/prepare-js-fixture.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-adapter-cloudflare/build.sh": { + "commit": "f721ddbe029eeb87941b9892c43e169993419dbe", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/elf.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/elf/loader.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/macho.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/macho/signature.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/tests.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/tests/builders.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/tests/builders/macho_data.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/tests/elf.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/tests/macho.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/tests/wasm.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/inspect/wasm.rs": { + "commit": "ee5598bf59d59ac2601f064397762738c69da63d", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/package.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/package/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/ambient.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/apple.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/apple/sdk.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/apple/toolchain.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/artifact.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/artifact/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/cargo_home.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/cargo_home/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/cargo_output.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/cargo_output/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/compare.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/completed.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/execute.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/fastly_tools.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/final_paths.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/fresh.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/gnu.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/gnu/image.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/gnu/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/identity.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/identity/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/native.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/paths.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/paths/fake.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/plan.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/remap.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/runtime.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/runtime/config.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/runtime/exchange.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/runtime/export.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/runtime/export/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/runtime/logs.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/runtime/viceroy.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/sources.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/startup.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/startup/accepted.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/startup/accepted/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/startup/lifecycle.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/startup/native.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/ambient.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/bind.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/candidate.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/cli.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/cli_candidate.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/compare.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/complete.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/complete_darwin.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/darwin.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/fixture.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/full.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/full_macos.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/macos_candidate.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/macos_cli.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/native.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/plan.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/real_fixture.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/remap.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/runtime.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/sources.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tests/tools.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/recipe/tools.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/build.sh": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/tests/cargo-fixture.Dockerfile": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/tests/cli-builder.Dockerfile": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/tests/prepare-cargo-fixture.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/tests/prepare-cli-fixture.mjs": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/schemas/SPDX-LICENSE": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/schemas/spdx-2.3.schema.json": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/capture.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/external.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/external/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/graph.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/graph/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/javascript.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/javascript/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/native.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/native/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/origin.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/origin/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/platform.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/platform/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/src/composition/tests.rs": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/composition/README.md": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-host-metadata.json": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-messages.jsonl": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-target-filtered-metadata.json": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-tree.txt": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-messages.jsonl": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-metadata.json": { + "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/qualification/results.schema.json": { + "commit": "1534d462d052debae8b5d886268701077bf879a5", + "state": "Ported Phase 2 source or approved split" + }, + ".github/workflows/format.yml": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Staged Phase 2 dependency/support subset" + }, + "CHANGELOG.md": { + "commit": "f721ddbe029eeb87941b9892c43e169993419dbe", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-adapter-spin/src/app.rs": { + "commit": "f721ddbe029eeb87941b9892c43e169993419dbe", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-adapter-spin/src/middleware.rs": { + "commit": "f721ddbe029eeb87941b9892c43e169993419dbe", + "state": "Ported Phase 2 source or approved split" + }, + "crates/trusted-server-adapter-spin/tests/routes.rs": { + "commit": "f721ddbe029eeb87941b9892c43e169993419dbe", + "state": "Ported Phase 2 source or approved split" + }, + "docs/guide/edgezero.md": { + "commit": "f721ddbe029eeb87941b9892c43e169993419dbe", + "state": "Ported Phase 2 source or approved split" + }, + "docs/guide/error-reference.md": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "docs/guide/getting-started.md": { + "commit": "f721ddbe029eeb87941b9892c43e169993419dbe", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/docs-proposal/hunks.mjs": { + "commit": "f721ddbe029eeb87941b9892c43e169993419dbe", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/docs-proposal/hunks.test.mjs": { + "commit": "f721ddbe029eeb87941b9892c43e169993419dbe", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/qualification/README.md": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + }, + "scripts/release/tests/fixtures/README.md": { + "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", + "state": "Ported Phase 2 source or approved split" + } + } +} diff --git a/docs/superpowers/specs/2026-09-25-git-version-header-design.md b/docs/superpowers/specs/2026-09-25-git-version-header-design.md index eca379c014..4dfe622d9a 100644 --- a/docs/superpowers/specs/2026-09-25-git-version-header-design.md +++ b/docs/superpowers/specs/2026-09-25-git-version-header-design.md @@ -23,6 +23,8 @@ monitors need to see from a response that production runs a release, and which one. Staging keeps deploying branches and commits, so it needs to be identifiable too. +> **Release-scope update:** Merged #1213's tag/branch/override examples describe development source builds. The [October release contract](2026-10-09-1235-release-attestation-design.md#r-facts--build-facts) adds strict `TRUSTED_SERVER__RELEASE_BUILD=1`: canonical attested builds require the approved full source commit, reject Git fallback, and return that commit in `x-ts-version`. The external release tag is recorded in the immutable release metadata (a signed release index is later work); header values do not prove attested bytes. Development goals and acceptance examples below remain applicable outside release mode. + ## Goals - Report the deployed git version in `x-ts-version`: the tag when the deployed diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index c96a3250dd..82dbc70fbb 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -74,7 +74,11 @@ which owns building and signing: accepts only two regular, non-symlink, non-empty files, records their digests, and runs two `actions/attest` calls: SLSA provenance over the binary and its SPDX file, then an SPDX SBOM attestation for the binary. It never checks out - or runs source. + or runs source. Phase 2 adds, for the Fastly leg only, the + `trusted-server-fastly.wasm.rebuild-check.json` companion as a third + provenance subject. Its inline validation runs on Node: the signing job + installs the pinned Node with `actions/setup-node`, and that runtime is + trusted as a hosted-runner tool while the job holds `id-token: write`. The caller's **release** job (same gate, `contents: write` only) checks out only `scripts/release/publish.mjs`, `scripts/release/release-notes.md` and diff --git a/docs/superpowers/specs/2026-10-28-1235-binary-attestations-phase2-design.md b/docs/superpowers/specs/2026-10-28-1235-binary-attestations-phase2-design.md new file mode 100644 index 0000000000..ee50095e79 --- /dev/null +++ b/docs/superpowers/specs/2026-10-28-1235-binary-attestations-phase2-design.md @@ -0,0 +1,68 @@ +# Binary attestations Phase 2: ordinary Fastly release equality + +**Issue:** #1235. **Date:** 2026-10-28. + +**Status:** Gate 1 design approved and implemented in #1449. This phase-specific spec records the design contract only; rehearsal transcripts, current verification and approval notes live in #1449 comments and #1235. It opens no later gates. + +## Goal and scope + +Before signing a release, build `trusted-server-fastly.wasm` twice in separate fresh hosted jobs and require equality of every final byte. Build and compare `trusted-server-fastly.tar.gz` as well, using the fixed service-neutral manifest and normalized archive metadata. Publish the real result as `trusted-server-fastly.wasm.rebuild-check.json`. The canonical build remains the released binary. + +The [approved Gate 1 design](../plans/binary-attestations-phase2-fastly-integration.md) defines the contract, trust flow and failure bounds. The [implementation plan](../plans/binary-attestations-phase2-fastly-integration-implementation.md) defines the concrete joins and tests. The [phase assignment](../plans/binary-attestations-phases-2-4.md) records the source-only producer port and later boundaries. These documents form this spec's detailed design. The [Phase 1 spec](2026-10-09-1235-release-attestation-design.md) remains the baseline; the Phase 3 Gate 2/3 proposals require separate approval. + +## Release path and authority + +Canonical and reproduction jobs execute the same ordinary release procedure from the exact source commit. They vary physical roots (including a private Cargo home per role), start time, job count and umask. Approved target-scoped compiler path remapping normalizes source identity before compilation; comparison retains debug information and excludes no final bytes. + +Read-only comparison checks current run/attempt, expected successful jobs, direct upload-action outputs, artifact API identities/digests and bounded archive contents. It joins retained build observations to both actual binaries, compares complete bytes and writes only a genuine matched companion. Failure cannot leave a publishable matched output. Observation, comparison and writer types confer no controlled-recipe qualification. + +All signing waits for Fastly equality. The isolated reusable `release-build.yml` signer performs no source checkout or producer execution. Fastly provenance names the wasm, SPDX, package and companion. Its SPDX attestation names both wasm and package; both existing bundle names remain unchanged. Before creating a release, the publisher checks exact handoff sets and signed-input receipts. Mutations remain single-shot and use the release ID; published verification remains bounded and read-only. + +## Compatibility, adapters and configuration + +The four binary names, SPDX 2.3 predicates/companions, reusable signer identity and existing binary verification commands remain stable. Publication adds the package and companion: 19 files, with `SHA256SUMS` covering the other 18. The companion has version 1, profile `ordinary-release-fastly-byte-equality-v1` and `release_qualification:false`. Consumers must check the profile. Later controlled evidence may use the same permanent filename only under its separately approved compatibility contract. + +Fastly alone gains an equality gate. The CLI binaries retain their native build/smoke requirements; native reproduction remains a closed-producer measurement capability rather than an ordinary-release second-build gate. The ordinary release matrix builds each native asset once. Cloudflare and Spin are outside the published cohort and gain no release gate. No runtime configuration, consent decision or request-data collection changes. Build observations use an explicit metadata allowlist, never the inherited environment or operator secrets. The public companion includes hosted checkout/target roots and Cargo homes, UID, start time, umask/job count and locale/timezone as build metadata. + +Build-only configuration remains separate from runtime config. Ordinary releases build fresh private JS bundles. If release builds select `TSJS_PREBUILT_DIR` or `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY`, both are required and the inventory path must be absolute; the ordinary producer refuses `TSJS_PREBUILT_DIR`. The opt-in `TRUSTED_SERVER__RELEASE_JS_TESTS` flag defaults to absent, accepts only `1` when present, and rejects the unsupported current esbuild lock before controlled tests run. + +The existing `RELEASE_PUBLICATION_ENABLED` variable still gates signing/publication only. Release CI runs the equality gate on every push to `main` and every `v*` tag; there is no separate equality switch. Maintainers accept the cost of a second Fastly build plus comparison on each of those runs. A mismatch fails the run: on `main` the commit lacks the passing release checks required before tagging, and on a tag it prevents release. Individual build and comparison ceilings remain 90 and 10 minutes; actual timing belongs to the exact rehearsal record. + +## Limits and failure handling + +The unused controlled producer graph, its tests, schemas, fixtures and `jsonschema` dependency are deferred to the phase that enables them. Phase 2 retains ordinary measurement/comparison, strict JSON and bounded I/O/process helpers, Cargo-message/path checks and Fastly package production/inspection. The lock starts from `main`, preserving its shipped dependency versions. Ordinary equality does not establish controlled material/tool/boundary qualification, complete composition, a release index or SLSA Level 3. The retained diagnostic JS scripts and their tests are separate from the deferred Rust controlled producer. Their full collector is skipped and guarded against the unsupported esbuild lock; ordinary builds do not use that collector. GNU masking WIP is excluded. Each role builds with its own fresh Cargo home, remapped to `/cargo-home`, so dependency source paths do not depend on the runner's home directory; both jobs still share the runner user and hosted image. + +Input, transfer, comparison or signing failure prevents release. A real mismatch requires a decision; exclusions and weaker equality are prohibited. Existing drafts/releases are refused rather than overwritten. A failure after publication leaves an immutable release and requires the guide's read-only recovery commands, never a replacement under the same tag. + +## Reproducible deployment package amendment + +The 2026-11-12 review response adds an operator-downloadable package while preserving every wasm name, verification command and the permanent rebuild-check filename. This changes the earlier wasm-only Gate 1 asset/subject sets; the amendment governs those sets. No later qualification, composition or index gate opens. + +The package writer uses pinned Rust `tar` and `flate2`, ustar headers, gzip level 9 with timestamp zero and OS 255, numeric owner/group zero, modes 0755 for directories and 0644 for files, and fixed member order: `trusted-server-fastly/`, `trusted-server-fastly/bin/`, `trusted-server-fastly/bin/main.wasm`, `trusted-server-fastly/fastly.toml`. The last file is the exact tracked two-line `scripts/release/fastly.toml`; the module is the exact role's final wasm. No filesystem metadata enters the archive. Writes refuse an existing destination, persist through a private temporary file and verify input/output readback. + +Private observations now require a package size/digest. Each authenticated build ZIP contains exactly wasm, SPDX and package. Comparison checks both observed package identities and compares complete archive bytes. The version-1 companion keeps all wasm fields and adds `package` with `asset`, `size`, `manifest_sha256`, `canonical_sha256` and `reproduction_sha256`; each build record also carries the observed package size/digest. Both package digests must match. This is additive ordinary evidence, never a reinterpretation as controlled qualification. + +The isolated signer admits four Fastly inputs, rechecks the package against the comparison and fixed manifest identity, attests all four in provenance and both deployable files in SPDX, and retains the same two bundle names. Native inputs remain binary plus SPDX. The publisher checks package handoff hashes and verifies its provenance and SPDX before any release mutation. The fixed set is 19 release files and 18 checksum entries. The operator deploys the verified package unchanged; local repacking remains a fallback. + +Phase 3 #1463 must restore its required deferred producer helpers in its own scope, reconcile the additive package evidence and update the index before landing. Packages PR #1543 must reconcile the extra release asset/subjects. Neither dependent branch is modified by this amendment. The optional GitHub custom-predicate redesign is deferred: it would change the reviewed transport/observation trust boundary and permanent companion contract. + +Regression coverage must reject a package-only mismatch despite equal wasm, changed observed package bytes, omitted package inputs/subjects and post-signing corruption. Metadata variation must preserve complete package bytes. The pinned Fastly CLI must accept the new writer's output; hosted CI and exact-input signed publication rehearsal remain separate verification obligations. Earlier `.39` evidence applies only to its original head and wasm-only release contract. + +## Verification and readiness + +Pure tests cover strict documents, source/tool/JS/environment joins, archive validation, atomic output and failure cleanup. Hosted process tests cover subprocess behavior, cancellation and negative comparator behavior, and contract tests require the real sign and publication jobs to skip after a failed comparison; local process tests are not substituted for hosted evidence. Release CI (every push to `main` and every `v*` tag) builds all four binaries and the additional Fastly reproduction; pull requests run the release Node suite and producer tests only. The pinned Fastly packaging route runs read-only in CI without deployment or local configuration changes. + +The release rehearsal must verify all 19 files, immutable/prerelease/not-Latest state, actual A/B joins, both predicates and identities online/offline for all four binaries, companion provenance, SPDX equality, sums, native smoke and pinned Fastly packaging. Exact-source rehearsal evidence is recorded in #1449 comments and the [compatibility and rehearsal record](../plans/binary-attestations-phase2-fastly-integration.md#compatibility-and-rehearsal-record). + +The user approved completing Gate 1’s structured failure diagnostics before the fresh rehearsal. Typed rejection checks classify a separate canonical diagnostic, capped at 4,096 bytes, with actual measured facts or explicit `unavailable` fields. It remains a CI-only failure artifact alongside bounded stderr, never a signed or published companion. Writer failure cannot change the failed result. The negative hosted fixture must observe `mismatched` at stage `bytes`. + +The existing parent issue #1235 tracks this phase; no separate tracking issue is needed. + +## Review log + +- Round 1 (2026-10-28, fresh independent AI source review of implementation `8a797dbcb` and this phase spec): found the inherited structured-failure-diagnostic promise unimplemented and the pagination claim inconsistent with the fail-closed single-page limit. The user approved implementing the diagnostic requirement before rehearsal; corrected the plan to its actual 100-record bound. Clarified native reproduction, build-only JS settings and public build metadata. Historical `.13` remains attributed only to its exact source. + +- Round 2 (2026-10-28, fresh independent AI source review of `8a797dbcb` plus the structured-diagnostic changes): no blocking spec findings. Confirmed the complete single-page transfer bound, native-once build scope, build-only configuration/privacy disclosure and typed diagnostic contract against source. Tests, final implementation/security review, exact-head CI and the current-input rehearsal remain separately recorded gates. + +- Round 3 (2026-11-12, independent AI source review of the deployment-package amendment): found that package-only inequality lost structured mismatch classification, retained Cargo-parser tests were missing, and unused fixture files survived the removal. Added package-specific measured failure facts and a command regression, ported the relevant parser tests, and removed the remaining fixtures. Added exact SPDX-subject coverage and offline package verification. +- Round 4 (2026-11-12, independent AI follow-up source review): no remaining blocking design findings. Confirmed both role validation calls, four-input Fastly provenance, two-subject SPDX, 19-file publication and the deferred-producer boundary. Local checks, current-head hosted CI and signed-publication rehearsal remain separate evidence. diff --git a/scripts/release/bootstrap.sh b/scripts/release/bootstrap.sh new file mode 100644 index 0000000000..e02fd3f659 --- /dev/null +++ b/scripts/release/bootstrap.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Rust release tooling must be source-approved before this launcher can execute it. +# Keep release execution closed while the replacement is implemented and reviewed. +set -eu +# An extensionless pin overrides rust-toolchain.toml during rustup selection, +# so reject it in the invocation directory and in the repository root that +# owns this script before any future toolchain selection can happen. +script_directory=$(dirname -- "$0") +repository_root=$(CDPATH='' cd -P -- "$script_directory/../.." && pwd -P) +for candidate in rust-toolchain "$repository_root/rust-toolchain"; do + if [ -e "$candidate" ] || [ -L "$candidate" ]; then + printf '%s\n' 'Extensionless rust-toolchain is rejected before toolchain selection' >&2 + exit 1 + fi +done +printf '%s\n' 'Release bootstrap is pending the reviewed Rust tooling replacement' >&2 +exit 1 diff --git a/scripts/release/build-js.sh b/scripts/release/build-js.sh new file mode 100755 index 0000000000..46f37e7981 --- /dev/null +++ b/scripts/release/build-js.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Use the same independently approved bootstrap boundary as the Rust recipe. +set -eu +exec /bin/sh "$(dirname "$0")/bootstrap.sh" js "$@" diff --git a/scripts/release/build.sh b/scripts/release/build.sh new file mode 100755 index 0000000000..e5e5dfd118 --- /dev/null +++ b/scripts/release/build.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Independent native/bootstrap approval precedes release-tool execution. +set -eu +exec /bin/sh "$(dirname "$0")/bootstrap.sh" rust "$@" diff --git a/scripts/release/collect-js.mjs b/scripts/release/collect-js.mjs new file mode 100644 index 0000000000..5a1ac57694 --- /dev/null +++ b/scripts/release/collect-js.mjs @@ -0,0 +1,252 @@ +/** + * Produce conservative post-Rollup contributions and final post-esbuild hashes. + * Later minification can eliminate recorded code; rendered_length is not final + * byte attribution. This producer does not prove authenticated acquisition, + * lifecycle execution, host qualification or A/B equality. Preparation + * observations and execution boundary qualification remain separate requirements. + */ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { buildAll } from '../../crates/trusted-server-js/lib/build-all.mjs' +import { + actualToolFiles, + installedOrigins, + installedMaterials, + validatePreparation, + releaseBuild, + readBounded, + strictJson, + sha256, +} from '../../crates/trusted-server-js/lib/release-composition.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const defaultRoot = path.resolve(here, '../..') +const MAX_BUNDLE = 32 * 1024 * 1024 + +/** Discover modules from the selected source identity. */ +export function expectedModuleIds(repoRoot, sourceCommit, sourceContextPath) { + if (!/^[a-f0-9]{40}$/.test(sourceCommit ?? '')) + throw new Error('Expected full source commit') + if (sourceContextPath) { + const file = fs.realpathSync(sourceContextPath) + if (file === repoRoot || file.startsWith(`${repoRoot}${path.sep}`)) + throw new Error('Source context must be outside the writable checkout') + const context = strictJson(readBounded(file)) + if ( + !context || + typeof context !== 'object' || + Array.isArray(context) || + Object.keys(context).sort().join(',') !== + 'module_ids,schema_version,source_commit' || + context.schema_version !== 1 || + context.source_commit !== sourceCommit || + !Array.isArray(context.module_ids) || + !context.module_ids.includes('core') || + context.module_ids.some( + (id) => typeof id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(id) + ) || + context.module_ids.some( + (id, index) => index > 0 && context.module_ids[index - 1] >= id + ) + ) + throw new Error('Invalid parent-derived source context') + // The parent derives this list from authenticated objects and mounts it + // read-only. Parsing it alone does not establish source/build completion. + return context.module_ids + } + const gitEnvironment = { + PATH: process.env.PATH, + TMPDIR: os.tmpdir(), + LANG: 'C', + LC_ALL: 'C', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_NO_REPLACE_OBJECTS: '1', + GIT_OPTIONAL_LOCKS: '0', + } + const raw = execFileSync( + 'git', + ['ls-tree', '-rz', '--name-only', sourceCommit], + { + cwd: repoRoot, + env: gitEnvironment, + maxBuffer: 1024 * 1024, + } + ) + const tracked = new TextDecoder('utf-8', { fatal: true }) + .decode(raw) + .split('\0') + .filter( + (file) => + file === 'crates/trusted-server-js/lib/src/core/index.ts' || + /^crates\/trusted-server-js\/lib\/src\/integrations\/[^/]+\/index\.ts$/.test( + file + ) + ) + const expected = tracked + .map((file) => + file.endsWith('/core/index.ts') ? 'core' : file.split('/').at(-2) + ) + .sort() + if ( + !expected.includes('core') || + expected.some((id) => !/^[a-z][a-z0-9_-]*$/.test(id)) || + new Set(expected).size !== expected.length + ) + throw new Error('Invalid tracked module identities') + return expected +} + +/** Build release bundles using checked observations from independent preparation. */ +export async function collectJs({ + repoRoot = defaultRoot, + outDir, + inventoryPath, + preparationPath, + sourceContextPath, +}) { + if (!preparationPath) + throw new Error('Checked preparation records are required') + if (!releaseBuild()) + throw new Error('Collector requires the existing release build control') + repoRoot = fs.realpathSync(repoRoot) + if (repoRoot !== fs.realpathSync(defaultRoot)) + throw new Error('Collector must execute from its own checkout') + const sourceCommit = process.env.TRUSTED_SERVER__GIT_VERSION + const libDir = path.join(repoRoot, 'crates/trusted-server-js/lib') + const lockFile = path.join(libDir, 'package-lock.json') + const owners = installedOrigins({ repoRoot, lockFile }) + const packages = installedMaterials(owners) + const strictPreparation = strictJson(readBounded(preparationPath)) + const prepared = validatePreparation(strictPreparation, { + installRoot: libDir, + packages, + }) + for (const actual of actualToolFiles({ installRoot: libDir })) { + const tool = prepared.tools.find( + (record) => record.name === actual.name && record.role === actual.role + ) + const material = prepared.materials.find( + (record) => record.id === tool?.material_id + ) + const distribution = strictPreparation.distributions.find( + (record) => record.material.id === tool?.material_id + ) + if ( + !tool || + tool.version !== actual.version || + material.sha256 !== actual.sha256 || + fs.realpathSync(distribution.path) !== fs.realpathSync(actual.path) + ) + throw new Error( + `Actual build tool digest or identity mismatch: ${actual.name}` + ) + } + fs.mkdirSync(path.resolve(outDir), { recursive: true }) + outDir = fs.realpathSync(path.resolve(outDir)) + inventoryPath = path.join( + fs.realpathSync(path.dirname(path.resolve(inventoryPath))), + path.basename(inventoryPath) + ) + if ( + inventoryPath === outDir || + inventoryPath.startsWith(`${outDir}${path.sep}`) + ) + throw new Error('Inventory must be outside the bundle directory') + const expected = expectedModuleIds(repoRoot, sourceCommit, sourceContextPath) + const records = await buildAll({ + outDir, + sourceDir: path.join(libDir, 'src'), + composition: { repoRoot, lockFile, owners }, + }) + if ( + JSON.stringify(records.map((record) => record.id)) !== + JSON.stringify(expected) + ) + throw new Error('Discovered module set differs from tracked source') + const actualFiles = fs.readdirSync(outDir).sort() + if ( + JSON.stringify(actualFiles) !== + JSON.stringify(expected.map((id) => `tsjs-${id}.js`).sort()) + ) + throw new Error('Unexpected bundle files') + const modules = records.map((record) => { + const bytes = readBounded(path.join(outDir, record.filename), MAX_BUNDLE) + if (!bytes.length) throw new Error('Empty release bundle') + new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return { + id: record.id, + filename: record.filename, + size: bytes.length, + sha256: sha256(bytes), + contributions: record.contributions, + } + }) + const sources = [ + 'package.json', + 'package-lock.json', + 'build-all.mjs', + 'build-module-options.mjs', + 'release-composition.mjs', + 'vitest.config.ts', + 'tsconfig.json', + ].map((file) => `crates/trusted-server-js/lib/${file}`) + sources.push('scripts/release/collect-js.mjs') + sources.push('scripts/release/observe-npm.mjs') + sources.push( + 'scripts/release/build-js.sh', + 'scripts/release/bootstrap.sh', + 'crates/trusted-server-release/Cargo.toml', + 'crates/trusted-server-release/src/main.rs' + ) + const materials = [ + ...prepared.materials, + ...sources.map((file) => ({ + kind: 'source', + id: `source:${file}`, + path: file, + sha256: sha256(readBounded(path.join(repoRoot, file))), + })), + ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + if ( + new Set(materials.map((material) => material.id)).size !== materials.length + ) + throw new Error('Duplicate combined material identity') + const inventory = { + schema_version: 1, + source_commit: sourceCommit, + modules, + tools: prepared.tools, + materials, + } + const bytes = Buffer.from(`${JSON.stringify(inventory)}\n`) + if (bytes.length > 1024 * 1024) throw new Error('Inventory exceeds 1 MiB') + fs.writeFileSync(inventoryPath, bytes, { flag: 'wx', mode: 0o600 }) + return inventory +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href +) { + const args = process.argv.slice(2) + if ( + ![6, 8].includes(args.length) || + args[0] !== '--out-dir' || + args[2] !== '--inventory' || + args[4] !== '--preparation' || + (args.length === 8 && args[6] !== '--source-context') + ) + throw new Error( + 'Usage: collect-js.mjs --out-dir --inventory --preparation [--source-context ]' + ) + await collectJs({ + outDir: args[1], + inventoryPath: args[3], + preparationPath: args[5], + sourceContextPath: args[7], + }) +} diff --git a/scripts/release/fastly.toml b/scripts/release/fastly.toml new file mode 100644 index 0000000000..4a8557d3de --- /dev/null +++ b/scripts/release/fastly.toml @@ -0,0 +1,2 @@ +manifest_version = 3 +name = "trusted-server-fastly" diff --git a/scripts/release/observe-npm.mjs b/scripts/release/observe-npm.mjs new file mode 100644 index 0000000000..0dedfd9702 --- /dev/null +++ b/scripts/release/observe-npm.mjs @@ -0,0 +1,685 @@ +/** Observe pinned npm execution; the Rust supervisor authenticates the sealed result. */ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { + actualToolFiles, + expectedLifecycleEvents, + fileDigest, + installedMaterials, + installedOrigins, + readBounded, + sha256, + strictJson, + validatePreparation, +} from '../../crates/trusted-server-js/lib/release-composition.mjs' + +const require = createRequire(import.meta.url) +const MAX_JSON = 1024 * 1024 +const MAX_FILE = 32 * 1024 * 1024 +const MAX_TREE = 2 * 1024 * 1024 * 1024 +const HOOKS = new Map([ + [ + 'core-js@3.48.0:postinstall', + 'node -e "try{require(\'./postinstall\')}catch(e){}"', + ], + ['esbuild@0.27.3:postinstall', 'node install.js'], +]) +const ROOT_HOOKS = [ + 'preinstall', + 'install', + 'postinstall', + 'prepublish', + 'preprepare', + 'prepare', + 'postprepare', + 'predependencies', + 'dependencies', + 'postdependencies', +] +const inside = (root, file) => + file === root || file.startsWith(`${root}${path.sep}`) +const ordered = (left, right) => (left < right ? -1 : left > right ? 1 : 0) +const fail = () => { + throw new Error('Release npm observation rejected') +} + +/** Parse a bounded parent request without accepting commands or hook-policy overrides. */ +export function parseRequest(bytes) { + const request = strictJson(bytes) + const keys = [ + 'schema_version', + 'key', + 'nonce', + 'source_commit', + 'repo_root', + 'cache', + 'guard_path', + 'native_esbuild', + ] + if ( + !request || + typeof request !== 'object' || + Array.isArray(request) || + Object.keys(request).sort().join(',') !== keys.sort().join(',') || + request.schema_version !== 1 || + !/^[a-f0-9]{64}$/.test(request.key ?? '') || + !/^[a-f0-9]{64}$/.test(request.nonce ?? '') || + !/^[a-f0-9]{40}$/.test(request.source_commit ?? '') + ) + fail() + validateNativeExpectation(request.native_esbuild) + for (const key of ['repo_root', 'cache', 'guard_path']) { + const value = request[key] + if ( + typeof value !== 'string' || + !path.isAbsolute(value) || + /[\\\x00-\x1f\x7f]/.test(value) || + value.split('/').some((part) => part === '.' || part === '..') || + fs.realpathSync(value) !== value + ) + fail() + } + if ( + inside(request.repo_root, request.cache) || + inside(request.cache, request.repo_root) || + inside(request.repo_root, request.guard_path) || + inside(request.cache, request.guard_path) || + !fs.lstatSync(request.repo_root).isDirectory() || + !fs.lstatSync(request.cache).isDirectory() || + !fs.lstatSync(request.guard_path).isFile() || + fs.lstatSync(request.guard_path).nlink !== 1 + ) + fail() + return request +} + +function validateNativeExpectation(native) { + if ( + !native || + typeof native !== 'object' || + Array.isArray(native) || + Object.keys(native).sort().join(',') !== 'lock_path,mode,sha256,size' || + !/^node_modules\/@esbuild\/linux-(?:x64|arm64)$/.test( + native.lock_path ?? '' + ) || + !/^[a-f0-9]{64}$/.test(native.sha256 ?? '') || + !Number.isSafeInteger(native.size) || + native.size < 1 || + native.size > MAX_FILE || + !Number.isSafeInteger(native.mode) || + native.mode < 0 || + native.mode > 0o777 || + !(native.mode & 0o111) + ) + fail() +} + +/** Detach only esbuild's reviewed native hard-link optimization without changing executable bytes. */ +export function normalizeEsbuildHardlink({ installRoot, native }) { + validateNativeExpectation(native) + const nativePath = `${native.lock_path}/bin/esbuild` + const ownedPath = 'node_modules/esbuild/bin/esbuild' + const original = path.join(installRoot, nativePath) + const owned = path.join(installRoot, ownedPath) + if ( + fs.realpathSync(original) !== original || + fs.realpathSync(owned) !== owned + ) + fail() + const first = fs.lstatSync(original) + const second = fs.lstatSync(owned) + if ( + !first.isFile() || + !second.isFile() || + first.size !== native.size || + (first.mode & 0o777) !== native.mode || + fileDigest(original) !== native.sha256 + ) + fail() + if (first.nlink === 1 && second.nlink === 1) return null + if ( + first.nlink !== 2 || + second.nlink !== 2 || + first.dev !== second.dev || + first.ino !== second.ino || + second.size !== native.size || + (second.mode & 0o777) !== native.mode || + fileDigest(owned) !== native.sha256 + ) + fail() + const temporary = path.join( + path.dirname(owned), + `.release-esbuild-${crypto.randomBytes(16).toString('hex')}` + ) + let descriptor + try { + descriptor = fs.openSync( + temporary, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW, + 0o600 + ) + const bytes = readBounded(original, MAX_FILE) + const held = fs.lstatSync(original) + if ( + bytes.length !== native.size || + sha256(bytes) !== native.sha256 || + held.dev !== first.dev || + held.ino !== first.ino || + held.nlink !== 2 || + held.size !== first.size || + held.mode !== first.mode || + held.mtimeMs !== first.mtimeMs || + held.ctimeMs !== first.ctimeMs + ) + fail() + let offset = 0 + while (offset < bytes.length) { + const written = fs.writeSync( + descriptor, + bytes, + offset, + bytes.length - offset + ) + if (!written) fail() + offset += written + } + fs.fchmodSync(descriptor, native.mode) + fs.closeSync(descriptor) + descriptor = undefined + if ( + fs.lstatSync(owned).ino !== second.ino || + fs.lstatSync(owned).dev !== second.dev + ) + fail() + fs.renameSync(temporary, owned) + const afterOriginal = fs.lstatSync(original) + const afterOwned = fs.lstatSync(owned) + if ( + afterOriginal.nlink !== 1 || + afterOwned.nlink !== 1 || + afterOwned.ino === afterOriginal.ino || + afterOwned.size !== native.size || + (afterOwned.mode & 0o777) !== native.mode || + fileDigest(original) !== native.sha256 || + fileDigest(owned) !== native.sha256 + ) + fail() + return { + kind: 'esbuild-hardlink-copy-v1', + path: ownedPath, + native_path: nativePath, + sha256: native.sha256, + size: native.size, + mode: native.mode, + } + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor) + fs.rmSync(temporary, { force: true }) + } +} + +/** Measure bounded file facts, rejecting special files, hard links and concurrent mutation. */ +export function snapshotTree(root) { + const facts = new Map() + let total = 0 + function visit(directory, depth) { + if (depth > 64) fail() + for (const name of fs.readdirSync(directory).sort(ordered)) { + if (!name || /[\\\x00-\x1f\x7f]/.test(name)) fail() + const file = path.join(directory, name) + const relative = path.relative(root, file).split(path.sep).join('/') + const stat = fs.lstatSync(file) + if (facts.size >= 100000) fail() + if (stat.isDirectory()) { + facts.set(relative, { kind: 'directory' }) + visit(file, depth + 1) + } else if (stat.isFile()) { + if ( + stat.nlink !== 1 || + stat.size > MAX_FILE || + (total += stat.size) > MAX_TREE + ) + fail() + const digest = fileDigest(file) + const after = fs.lstatSync(file) + if ( + after.dev !== stat.dev || + after.ino !== stat.ino || + after.nlink !== 1 || + after.size !== stat.size || + after.mtimeMs !== stat.mtimeMs || + after.ctimeMs !== stat.ctimeMs || + after.mode !== stat.mode + ) + fail() + facts.set(relative, { + kind: 'file', + size: stat.size, + executable: Boolean(stat.mode & 0o111), + sha256: digest, + }) + } else if (stat.isSymbolicLink()) { + const target = fs.readlinkSync(file) + if ( + path.isAbsolute(target) || + /[\\\x00-\x1f\x7f]/.test(target) || + !inside(root, fs.realpathSync(file)) + ) + fail() + facts.set(relative, { kind: 'symlink', target }) + } else fail() + } + } + visit(root, 0) + return facts +} + +function changesAfter(before, after, owner) { + const paths = [...new Set([...before.keys(), ...after.keys()])].sort(ordered) + const changes = [] + for (const filename of paths) { + const first = before.get(filename) ?? null + const last = after.get(filename) ?? null + if (JSON.stringify(first) === JSON.stringify(last)) continue + if (!filename.startsWith(`${owner}/`)) fail() + changes.push({ path: filename, before: first, after: last }) + } + return changes +} + +/** Adapt exact pinned npm lifecycle calls before npm loads its dependency graph. */ +export function installLifecycleObserver({ + npmRoot, + installRoot, + nodePath, + nativeEsbuild, +}) { + if ( + strictJson(readBounded(path.join(npmRoot, 'package.json'))).version !== + '11.19.0' || + process.versions.node !== '24.20.0' || + fs.realpathSync(nodePath) !== fs.realpathSync(process.execPath) + ) + fail() + const lock = strictJson( + readBounded(path.join(installRoot, 'package-lock.json')) + ) + const manifest = strictJson( + readBounded(path.join(installRoot, 'package.json')) + ) + if ( + lock.lockfileVersion !== 3 || + !lock.packages || + ROOT_HOOKS.some((event) => Object.hasOwn(manifest.scripts ?? {}, event)) || + fs.existsSync(path.join(installRoot, 'binding.gyp')) + ) + fail() + const root = path.join(npmRoot, 'node_modules/@npmcli/run-script') + const entry = path.join(root, 'lib/run-script.js') + const spawnEntry = path.join(root, 'lib/make-spawn-args.js') + // Already captured imports would retain an unobserved lifecycle function. + if ( + require.cache[entry] || + require.cache[path.join(root, 'lib/run-script-pkg.js')] + ) + fail() + const makeSpawnArgs = require(spawnEntry) + const events = [] + const normalizations = [] + let active = null + let rejected = false + require.cache[spawnEntry].exports = (options) => { + if ( + !active || + options.event !== active.event || + options.path !== active.path || + options.cmd !== active.command || + options.args?.length + ) { + rejected = true + fail() + } + const result = makeSpawnArgs(options) + const spawn = result[2] + // npm prepends package bins; bind node after that transformation too. + for (const key of Object.keys(spawn.env)) { + if (/^path$/i.test(key)) + spawn.env[key] = + `${path.dirname(nodePath)}${path.delimiter}${spawn.env[key]}` + if (/^(?:NODE_OPTIONS|NODE_PATH)/.test(key)) delete spawn.env[key] + } + // Both reviewed commands start with bare node; resolve that word independently of PATH. + result[0] = `'${nodePath.replaceAll("'", "'\\''")}'${active.command.slice(4)}` + spawn.stdio = ['ignore', 'inherit', 'inherit'] + active.spawned = true + return result + } + const original = require(entry) + const observed = async (options) => { + let row + let before + try { + if (active || !options || typeof options.path !== 'string') fail() + const owner = fs.realpathSync(options.path) + // npm invokes absent root hooks too. Preserve those real no-op calls, never a root command. + if (owner === installRoot) { + const current = strictJson( + readBounded(path.join(installRoot, 'package.json')) + ) + if ( + !ROOT_HOOKS.includes(options.event) || + options.cmd !== undefined || + options.args?.length || + ROOT_HOOKS.some((event) => + Object.hasOwn(current.scripts ?? {}, event) + ) || + fs.existsSync(path.join(installRoot, 'binding.gyp')) + ) + fail() + return await original(options) + } + const location = path + .relative(installRoot, owner) + .split(path.sep) + .join('/') + const locked = lock.packages[location] + const pkg = strictJson(readBounded(path.join(owner, 'package.json'))) + const command = pkg.scripts?.[options.event] + const expected = HOOKS.get(`${pkg.name}@${pkg.version}:${options.event}`) + const lockName = location + .split('/node_modules/') + .at(-1) + .replace(/^node_modules\//, '') + if ( + !inside(installRoot, owner) || + owner === installRoot || + !locked || + pkg.name !== lockName || + pkg.version !== locked.version || + !expected || + command !== expected || + options.cmd !== undefined || + options.args?.length || + options.pkg?.name !== pkg.name || + options.pkg?.version !== pkg.version || + options.pkg?.scripts?.[options.event] !== command || + events.some( + (event) => + event.stage === 'start' && + event.lock_path === location && + event.event === options.event + ) + ) + fail() + before = snapshotTree(installRoot) + if (nativeEsbuild && location === 'node_modules/esbuild') { + validateNativeExpectation(nativeEsbuild) + const native = before.get(`${nativeEsbuild.lock_path}/bin/esbuild`) + if ( + native?.kind !== 'file' || + native.sha256 !== nativeEsbuild.sha256 || + native.size !== nativeEsbuild.size || + native.executable !== true || + lock.packages[nativeEsbuild.lock_path]?.version !== '0.27.3' + ) + fail() + } + row = { + lock_path: location, + event: options.event, + script_sha256: sha256(command), + } + events.push({ ...row, stage: 'start', changes: [] }) + active = { ...row, path: options.path, command, spawned: false } + const result = await original(options) + if (!active.spawned || result.code !== 0 || result.signal) fail() + if (nativeEsbuild && location === 'node_modules/esbuild') { + const normalization = normalizeEsbuildHardlink({ + installRoot, + native: nativeEsbuild, + }) + if (normalization) normalizations.push(normalization) + } + const changes = changesAfter(before, snapshotTree(installRoot), location) + events.push({ + ...row, + stage: 'success', + exit_code: result.code, + signal: null, + changes, + }) + return result + } catch (error) { + rejected = true + if (row) { + let changes = [] + try { + changes = changesAfter( + before, + snapshotTree(installRoot), + row.lock_path + ) + } catch { + /* Failed observations authorize nothing. */ + } + events.push({ + ...row, + stage: 'failure', + exit_code: Number.isInteger(error?.code) ? error.code : null, + signal: typeof error?.signal === 'string' ? error.signal : null, + changes, + }) + } + throw error + } finally { + active = null + } + } + Object.assign(observed, original) + require.cache[entry].exports = observed + return { events, normalizations, failed: () => rejected || active !== null } +} + +/** Measure preparation using existing collector semantics after actual successful hooks. */ +export function buildPreparation({ repoRoot, observation }) { + if (observation.failed()) fail() + const installRoot = path.join(repoRoot, 'crates/trusted-server-js/lib') + const owners = installedOrigins({ + repoRoot, + lockFile: path.join(installRoot, 'package-lock.json'), + }) + const packages = installedMaterials(owners) + const lifecycle = observation.events.filter( + (event) => event.stage === 'success' + ) + const expected = expectedLifecycleEvents({ installRoot, packages }) + if (lifecycle.length !== expected.length) fail() + const lifecycle_events = expected.map((event) => { + if ( + !lifecycle.some( + (row) => + `npm:${row.lock_path}` === event.package_material_id && + row.event === event.event && + row.script_sha256 === event.script_sha256 + ) + ) + fail() + return event + }) + const lifecycle_outputs = lifecycle.flatMap((event) => + event.changes + .filter((change) => change.after?.kind === 'file') + .map((change) => ({ + kind: 'lifecycle-output', + id: `lifecycle-output:${event.lock_path}:${event.event}:${change.path}`, + package_material_id: `npm:${event.lock_path}`, + event: event.event, + script_sha256: event.script_sha256, + path: change.path, + size: change.after.size, + sha256: change.after.sha256, + })) + ) + const measured = actualToolFiles({ installRoot }) + const distributions = measured.map((tool) => ({ + material: { + kind: 'tool-distribution', + id: `tool:${tool.name}`, + name: tool.name, + version: tool.version, + sha256: tool.sha256, + }, + path: tool.path, + })) + const tools = measured.map((tool) => ({ + name: tool.name, + version: tool.version, + role: tool.role, + material_id: `tool:${tool.name}`, + })) + const preparation = { + schema_version: 1, + packages, + distributions, + tools, + lifecycle_events, + lifecycle_outputs, + } + validatePreparation(preparation, { installRoot, packages }) + return preparation +} + +/** Seal exact JSON bytes; child output cannot forge a valid result without the parent key. */ +export function sealObservation(value, key) { + const bytes = Buffer.from(JSON.stringify(value)) + if (bytes.length > MAX_JSON || !/^[a-f0-9]{64}$/.test(key)) fail() + const signature = crypto + .createHmac('sha256', Buffer.from(key, 'hex')) + .update(bytes) + .digest('hex') + return `TS_RELEASE_NPM_OBSERVATION_V1 ${bytes.toString('base64')} ${signature}\n` +} + +function readRequest() { + const bytes = Buffer.alloc(MAX_JSON + 1) + let count = 0 + while (count < bytes.length) { + const read = fs.readSync(0, bytes, count, bytes.length - count, null) + if (!read) break + count += read + } + if (count > MAX_JSON) fail() + return parseRequest(bytes.subarray(0, count)) +} + +function main() { + // Protect the trusted npm process before reading the parent's private key. + // The Rust supervisor authenticates and mounts this selected native guard read-only. + if ( + process.platform !== 'linux' || + process.argv.length !== 4 || + process.argv[2] !== '--guard' + ) + fail() + const guard = process.argv[3] + if ( + !path.isAbsolute(guard) || + !guard.endsWith('.node') || + fs.realpathSync(guard) !== guard || + /[\\\x00-\x1f\x7f]/.test(guard) || + guard.split('/').some((part) => part === '.' || part === '..') || + require(guard).protected !== true + ) + fail() + const request = readRequest() + const key = request.key + delete request.key + if ( + process.platform !== 'linux' || + request.guard_path !== guard || + process.cwd() !== + path.join(request.repo_root, 'crates/trusted-server-js/lib') || + process.env.NODE_OPTIONS !== undefined || + process.env.NODE_PATH !== undefined || + Object.keys(process.env).some((name) => /^npm_config_/i.test(name)) || + fs.existsSync(path.join(process.cwd(), '.npmrc')) || + fs.existsSync(path.join(request.cache, 'release-disabled-global.npmrc')) || + process.env.TRUSTED_SERVER__RELEASE_BUILD !== '1' || + process.env.TRUSTED_SERVER__GIT_VERSION !== request.source_commit + ) + fail() + const npmRoot = path.resolve( + path.dirname(process.execPath), + '../lib/node_modules/npm' + ) + const observation = installLifecycleObserver({ + npmRoot, + installRoot: process.cwd(), + nodePath: process.execPath, + nativeEsbuild: request.native_esbuild, + }) + process.once('exit', (code) => { + let preparation = null + let success = code === 0 && !observation.failed() + if (success) { + try { + preparation = buildPreparation({ + repoRoot: request.repo_root, + observation, + }) + } catch { + success = false + } + } + const summary = { + ...request, + success, + npm_exit_code: code, + events: observation.events, + normalizations: observation.normalizations, + preparation, + } + try { + fs.writeSync(1, `\n${sealObservation(summary, key)}`) + } catch { + success = false + } + if (!success) process.exitCode = 1 + }) + process.argv = [ + process.execPath, + path.join(npmRoot, 'bin/npm-cli.js'), + 'ci', + '--offline', + '--include=dev', + '--no-audit', + '--no-fund', + '--foreground-scripts', + '--ignore-scripts=false', + '--userconfig=/dev/null', + `--globalconfig=${path.join(request.cache, 'release-disabled-global.npmrc')}`, + '--script-shell=/bin/sh', + '--progress=false', + '--cache', + request.cache, + ] + require(path.join(npmRoot, 'bin/npm-cli.js')) +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + main() + } catch { + process.stderr.write('Release npm observation rejected\n') + process.exitCode = 1 + } +} diff --git a/scripts/release/phase1-build.sh b/scripts/release/phase1-build.sh index 75aef98cd0..9b885e9223 100644 --- a/scripts/release/phase1-build.sh +++ b/scripts/release/phase1-build.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Phase 1 ordinary hosted build; no reproducibility or complete composition claim. +# Ordinary hosted release build; controlled-recipe qualification remains closed. set -euo pipefail build_package=${1:?package required} build_target=${2:?target required} @@ -19,6 +19,32 @@ for variable in TSJS_SKIP_BUILD TSJS_PREBUILT_DIR TSJS_TEST; do exit 1 fi done +ordinary_role=${4:-} +if [[ "$build_target" == wasm32-wasip1 ]]; then + case "$ordinary_role" in + canonical) ordinary_jobs=4; umask 022 ;; + reproduction) ordinary_jobs=2; umask 027 ;; + *) echo 'Fastly requires its fixed ordinary build role' >&2; exit 1 ;; + esac + : "${ORDINARY_RELEASE_PRODUCER:?}" + [[ -x "$ORDINARY_RELEASE_PRODUCER" ]] || { echo "Ordinary release producer is not executable" >&2; exit 1; } + [[ "$(pwd -P)" == "$PWD" ]] || { echo "Checkout path must not contain symlinks" >&2; exit 1; } + export LC_ALL=C TZ=UTC CARGO_BUILD_JOBS="$ordinary_jobs" + # Each role fetches its dependency sources into its own fresh Cargo home, + # which the compiler remaps to /cargo-home. + export CARGO_HOME="$RUNNER_TEMP/ordinary-$ordinary_role-cargo-home" + [[ ! -e "$CARGO_HOME" ]] || { echo "Refusing existing $CARGO_HOME" >&2; exit 1; } + mkdir "$CARGO_HOME" + ordinary_started_at=$(date +%s) + ordinary_umask=$(umask) + ordinary_rustc=$(rustup which rustc) + ordinary_node=$(node -p 'require("node:fs").realpathSync(process.execPath)') + ordinary_messages="$RUNNER_TEMP/ordinary-cargo.jsonl" + [[ ! -e "$ordinary_messages" ]] || { echo "Refusing existing $ordinary_messages" >&2; exit 1; } +elif [[ -n "$ordinary_role" ]]; then + echo 'Ordinary equality roles are Fastly-only' >&2 + exit 1 +fi # No operator values enter through inherited environment; these fictional canaries # additionally catch known config embedding regressions in the final executable. export TRUSTED_SERVER__RELEASE_BUILD=1 @@ -53,9 +79,18 @@ proxy_secret = "phase1-secret-canary-32-characters" policy = "phase1-permission-policy-canary" CONFIG export CARGO_TARGET_DIR="$RUNNER_TEMP/phase1-target" +if [[ "$build_target" == wasm32-wasip1 ]]; then + export CARGO_TARGET_DIR="$RUNNER_TEMP/ordinary-fastly-$ordinary_role-target" +fi # Exit explicitly: under bash 3.2 (macOS /bin/bash) a failing standalone [[ ]] # does not stop set -e. [[ ! -e "$CARGO_TARGET_DIR" ]] || { echo "Refusing existing target directory: $CARGO_TARGET_DIR" >&2; exit 1; } +if [[ "$build_target" == wasm32-wasip1 ]]; then + mkdir "$CARGO_TARGET_DIR" + ordinary_rustflags=$("$ORDINARY_RELEASE_PRODUCER" ordinary-rustflags \ + --checkout "$PWD" --target "$CARGO_TARGET_DIR" --role "$ordinary_role" \ + --output "$RUNNER_TEMP/ordinary-rustflags") +fi mkdir -p release-assets [[ -z "$(ls -A release-assets)" ]] || { echo "Refusing non-empty release-assets" >&2; exit 1; } # The pinned Node distribution includes npm. npm verifies lockfile integrity for @@ -91,9 +126,18 @@ DOCKERFILE rustup run "$rust_version" cargo build --package "$1" --release --target "$2" --locked ' phase1 "$build_package" "$build_target" else - cargo build --package "$build_package" --release --target "$build_target" --locked + if [[ "$build_target" == wasm32-wasip1 ]]; then + cargo --config "$ordinary_rustflags" build --package "$build_package" --release --target "$build_target" --locked \ + --message-format=json-render-diagnostics > "$ordinary_messages" + else + cargo build --package "$build_package" --release --target "$build_target" --locked + fi fi cp "$CARGO_TARGET_DIR/$build_target/release/$executable" "release-assets/$release_asset" +if [[ "$build_target" == wasm32-wasip1 ]]; then + "$ORDINARY_RELEASE_PRODUCER" pack-fastly-package --wasm "release-assets/$release_asset" \ + --output release-assets/trusted-server-fastly.tar.gz +fi shasum -a 256 "release-assets/$release_asset" > "$RUNNER_TEMP/binary.sha256" node scripts/release/phase1.mjs check "release-assets/$release_asset" node scripts/release/phase1.mjs smoke "release-assets/$release_asset" "$build_target" @@ -110,7 +154,7 @@ CARGO_TARGET_DIR="$tool_root/target" cargo install \ --path "$tool_root/cargo-cyclonedx-0.5.9" --locked --root "$tool_root/install" # The generator resolves the whole workspace, including unbuilt members. Acquire # their selected sources under the lock before denying network resolution. -cargo metadata --locked --format-version 1 --filter-platform "$build_target" > /dev/null +cargo metadata --locked --format-version 1 --filter-platform "$build_target" > "$tool_root/cargo-metadata.json" # Pinned generator has no --locked flag. Resolve offline after the locked build # with identical target/default features, then require the lock to be unchanged. CARGO_NET_OFFLINE=true "$tool_root/install/bin/cargo-cyclonedx" cyclonedx \ @@ -145,3 +189,40 @@ node scripts/release/phase1.mjs validate-spdx \ "$tool_root/combined.cdx.json" shasum -a 256 --check "$RUNNER_TEMP/binary.sha256" git diff --exit-code -- Cargo.lock crates/trusted-server-js/lib/package-lock.json edgezero.toml +if [[ "$build_target" == wasm32-wasip1 ]]; then + ORDINARY_ROLE="$ordinary_role" ORDINARY_STARTED_AT="$ordinary_started_at" \ + ORDINARY_UMASK="$ordinary_umask" ORDINARY_RUSTC="$ordinary_rustc" \ + ORDINARY_NODE="$ordinary_node" ORDINARY_MESSAGES="$ordinary_messages" \ + ORDINARY_METADATA="$tool_root/cargo-metadata.json" \ + ORDINARY_RUSTFLAGS="$RUNNER_TEMP/ordinary-rustflags/rustflags.json" \ + node - "$RUNNER_TEMP/ordinary-capture.json" <<'JAVASCRIPT' +const fs = require('node:fs') +const env = process.env +const capture = { + role: env.ORDINARY_ROLE, + source_commit: env.SOURCE_COMMIT, + environment: { + checkout_root: fs.realpathSync(process.cwd()), + target_root: fs.realpathSync(env.CARGO_TARGET_DIR), + cargo_home: fs.realpathSync(env.CARGO_HOME), + started_at_seconds: Number(env.ORDINARY_STARTED_AT), + umask: parseInt(env.ORDINARY_UMASK, 8), + jobs: Number(env.CARGO_BUILD_JOBS), + locale: env.LC_ALL, timezone: env.TZ, uid: process.getuid(), + }, + rustc: env.ORDINARY_RUSTC, node: env.ORDINARY_NODE, + cargo_messages: env.ORDINARY_MESSAGES, cargo_metadata: env.ORDINARY_METADATA, + rustflags: env.ORDINARY_RUSTFLAGS, +} +if ( + capture.environment.target_root !== env.CARGO_TARGET_DIR || + capture.environment.cargo_home !== env.CARGO_HOME +) + throw Error('Compiler-visible ordinary roots must be canonical') +fs.writeFileSync(process.argv[2], JSON.stringify(capture) + '\n', { flag: 'wx', mode: 0o600 }) +JAVASCRIPT + "$ORDINARY_RELEASE_PRODUCER" ordinary-measure \ + --request "$RUNNER_TEMP/ordinary-capture.json" \ + --output "$RUNNER_TEMP/fastly-observation" + shasum -a 256 --check "$RUNNER_TEMP/binary.sha256" +fi diff --git a/scripts/release/phase1.mjs b/scripts/release/phase1.mjs index 48346f1c8d..df5757470b 100644 --- a/scripts/release/phase1.mjs +++ b/scripts/release/phase1.mjs @@ -1,10 +1,369 @@ /** Small Phase 1 build-inventory and configuration checks, without release authority. */ import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' -import { readFileSync, statSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { + readFileSync, + statSync, + writeFileSync, + openSync, + closeSync, + fsyncSync, + realpathSync, + mkdirSync, +} from 'node:fs' +import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +/** Retains CI-only acquisition failure facts without environment or error text. */ +export function unavailableOrdinaryDiagnostic(directory) { + const facts = Object.fromEntries( + [ + 'canonical_artifact_id', + 'canonical_observation_artifact_id', + 'canonical_sha256', + 'canonical_size', + 'canonical_window', + 'first_difference_offset', + 'reproduction_artifact_id', + 'reproduction_observation_artifact_id', + 'reproduction_sha256', + 'reproduction_size', + 'reproduction_window', + ].map((key) => [key, 'unavailable']) + ) + const bytes = + JSON.stringify({ + code: 'transfer_unavailable', + facts, + kind: 'ordinary-comparison-diagnostic', + outcome: 'unavailable', + stage: 'transfer', + version: 1, + }) + '\n' + if (Buffer.byteLength(bytes) > 4096) + throw Error('Ordinary diagnostic exceeds cap') + mkdirSync(directory, { mode: 0o700 }) + writeFileSync(join(directory, 'ordinary-comparison-diagnostic.json'), bytes, { + flag: 'wx', + mode: 0o600, + }) +} + +/** Waits before retrying an idempotent ordinary read. */ +const ordinaryRetryDelays = [1_000, 3_000] + +/** Marks a transport error or transient HTTP status as safe to retry. */ +class OrdinaryRetryable extends Error {} + +function ordinaryWait(milliseconds, deadline) { + return new Promise((resolve, reject) => { + if (deadline?.aborted) + return reject(Error('Ordinary transfer deadline exhausted')) + const timer = setTimeout(resolve, milliseconds) + deadline?.addEventListener( + 'abort', + () => { + clearTimeout(timer) + reject(Error('Ordinary transfer deadline exhausted')) + }, + { once: true } + ) + }) +} + +/** + * Retries an idempotent GET at most twice after a network error, 5xx or 429, + * inside the shared transfer deadline. Other failures are never retried. + */ +async function ordinaryRetry(operation, deadline, wait) { + for (let attempt = 0; ; attempt++) { + try { + return await operation() + } catch (error) { + if ( + !(error instanceof OrdinaryRetryable) || + attempt >= ordinaryRetryDelays.length + ) + throw error + await wait(ordinaryRetryDelays[attempt], deadline) + } + } +} + +function transientStatus(status) { + return status === 429 || (status >= 500 && status <= 599) +} + +/** Fetches only fixed read-only current-run metadata, retaining strict-parser bytes. */ +export async function ordinaryApi( + path, + token, + fetcher = fetch, + deadline, + wait = ordinaryWait +) { + if ( + !/^\/repos\/[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*\/actions\/runs\/[1-9][0-9]*(?:\/attempts\/[1-9][0-9]*\/jobs|\/artifacts)\?per_page=100$/.test( + path + ) || + !token + ) + throw Error('Invalid ordinary metadata request') + const response = await ordinaryRetry( + async () => { + const response = await ordinaryFetch( + // Fixed GitHub Actions metadata service, not an integration destination. + `https://api.github.com${path}`, // allow-domain: api.github.com + { + method: 'GET', + redirect: 'manual', + signal: AbortSignal.any([ + AbortSignal.timeout(10_000), + ...(deadline ? [deadline] : []), + ]), + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + fetcher, + deadline + ) + if (transientStatus(response.status)) { + discardOrdinaryBody(response) + throw new OrdinaryRetryable(`Ordinary metadata HTTP ${response.status}`) + } + return response + }, + deadline, + wait + ) + if (response.status !== 200) { + discardOrdinaryBody(response) + throw Error(`Ordinary metadata HTTP ${response.status}`) + } + const chunks = [] + await ordinaryBody(response, 1024 * 1024, (chunk) => + chunks.push(Buffer.from(chunk)) + ) + return Buffer.concat(chunks) +} + +/** Downloads one already-bound archive by exact ID, without forwarding credentials. */ +export async function ordinaryDownload( + request, + fetcher = fetch, + wait = ordinaryWait +) { + const { repository, archive, token, directory, deadline } = request + const limits = { + 'canonical.zip': 768 * 1024 * 1024, + 'canonical-observation.zip': 2 * 1024 * 1024, + 'reproduction.zip': 768 * 1024 * 1024, + 'reproduction-observation.zip': 2 * 1024 * 1024, + } + if ( + !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test( + repository + ) || + repository.length > 200 || + !token || + !archive || + !Number.isSafeInteger(archive.id) || + archive.id <= 0 || + !Number.isSafeInteger(archive.size) || + archive.size <= 0 || + !Object.hasOwn(limits, archive.file) || + archive.size > limits[archive.file] || + !/^[a-f0-9]{64}$/.test(archive.sha256) || + realpathSync(directory) !== directory + ) + throw Error('Invalid bound ordinary archive request') + let descriptor + try { + descriptor = openSync(join(directory, archive.file), 'wx', 0o600) + // Each retry requests a fresh redirect: the signed blob URL is short-lived. + // Only the requests are retried; a failed body stream is not. + const blob = await ordinaryRetry( + async () => { + const signal = AbortSignal.any([ + AbortSignal.timeout(60_000), + ...(deadline ? [deadline] : []), + ]) + const response = await ordinaryFetch( + // Fixed artifact API returns the authenticated download redirect. + `https://api.github.com/repos/${repository}/actions/artifacts/${archive.id}/zip`, // allow-domain: api.github.com + { + method: 'GET', + redirect: 'manual', + signal, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + fetcher, + deadline + ) + const location = response.headers.get('location') + discardOrdinaryBody(response) + if (transientStatus(response.status)) + throw new OrdinaryRetryable('Ordinary archive API is unavailable') + if (response.status !== 302) + throw Error('Ordinary archive API did not return its single redirect') + const url = new URL(location) + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + url.hash + ) + throw Error('Ordinary archive redirect rejected') + const blob = await ordinaryFetch( + url.href, + { method: 'GET', redirect: 'error', signal }, + fetcher, + deadline + ) + if (blob.status !== 200) { + discardOrdinaryBody(blob) + if (transientStatus(blob.status)) + throw new OrdinaryRetryable('Ordinary archive blob is unavailable') + throw Error('Ordinary archive blob response rejected') + } + return blob + }, + deadline, + wait + ) + const hash = createHash('sha256') + const size = await ordinaryBody(blob, archive.size, (chunk) => { + hash.update(chunk) + writeFileSync(descriptor, chunk) + }) + if (size !== archive.size || hash.digest('hex') !== archive.sha256) + throw Error('Ordinary archive digest or size mismatch') + fsyncSync(descriptor) + } catch { + throw Error( + 'Ordinary archive acquisition failed; no comparison result is available' + ) + } finally { + if (descriptor !== undefined) closeSync(descriptor) + } +} + +/** Acquires four archives only after the Rust current-run binding accepts metadata. */ +export async function acquireOrdinaryTransfers( + request, + fetcher = fetch, + planner = ordinaryPlan +) { + const { producer, input, archives, repository, runId, attempt, token } = + request + if ( + !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test( + repository + ) || + repository.length > 200 || + !/^[1-9][0-9]*$/.test(runId) || + !/^[1-9][0-9]*$/.test(attempt) || + !Number.isSafeInteger(+runId) || + !Number.isSafeInteger(+attempt) || + realpathSync(input) !== input || + !token + ) + throw Error('Invalid ordinary acquisition context') + const prefix = `/repos/${repository}/actions/runs/${runId}` + const deadline = AbortSignal.timeout(300_000) + for (const [file, endpoint] of [ + ['jobs.json', `${prefix}/attempts/${attempt}/jobs?per_page=100`], + ['artifacts.json', `${prefix}/artifacts?per_page=100`], + ]) + writeFileSync( + join(input, file), + await ordinaryApi(endpoint, token, fetcher, deadline), + { flag: 'wx', mode: 0o600 } + ) + const plan = planner(producer, input) + deadline.throwIfAborted() + const names = [ + 'canonical.zip', + 'canonical-observation.zip', + 'reproduction.zip', + 'reproduction-observation.zip', + ] + if ( + plan?.version !== 1 || + !Array.isArray(plan.archives) || + plan.archives.length !== 4 || + !plan.archives.every((archive, index) => archive?.file === names[index]) || + new Set(plan.archives.map((archive) => archive.id)).size !== 4 + ) + throw Error('Ordinary bound transfer plan rejected') + mkdirSync(archives, { mode: 0o700 }) + for (const archive of plan.archives) + await ordinaryDownload( + { repository, archive, token, directory: archives, deadline }, + fetcher + ) +} + +function ordinaryPlan(producer, input) { + try { + return JSON.parse( + execFileSync(producer, ['ordinary-transfer-plan', '--input', input], { + encoding: 'utf8', + timeout: 30_000, + killSignal: 'SIGKILL', + maxBuffer: 1024 * 1024, + env: { PATH: process.env.PATH, LC_ALL: 'C', TZ: 'UTC' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + ) + } catch { + throw Error('Ordinary current-run transfer binding failed') + } +} + +async function ordinaryFetch(url, options, fetcher, deadline) { + try { + options.signal.throwIfAborted() + return await fetcher(url, options) + } catch { + // A request timeout or network error is retryable; the shared deadline is not. + if (deadline?.aborted) throw Error('Ordinary read-only transport failed') + throw new OrdinaryRetryable('Ordinary read-only transport failed') + } +} + +function discardOrdinaryBody(response) { + response.body?.cancel().catch(() => {}) +} + +async function ordinaryBody(response, limit, consume) { + const reader = response.body?.getReader() + if (!reader) throw Error('Ordinary response body is missing') + let size = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) return size + size += value.byteLength + if (size > limit) throw Error('Ordinary response exceeds its byte bound') + consume(value) + } + } catch { + throw Error('Ordinary response body failed its bounded read') + } finally { + await reader.cancel().catch(() => {}) + reader.releaseLock() + } +} + /** Check minimal wasm headers and final Linux loader requirements. */ export function checkReleaseFormat(bytes, target, elf = '') { if (target === 'wasm32-wasip1') { @@ -336,6 +695,19 @@ if ( } } else if (command === 'smoke' && args.length === 2) { await smokeRelease(...args) + } else if (command === 'ordinary-diagnostic' && args.length === 1) { + unavailableOrdinaryDiagnostic(args[0]) + } else if (command === 'ordinary-fetch' && args.length === 3) { + const [producer, input, archives] = args + await acquireOrdinaryTransfers({ + producer, + input, + archives, + repository: process.env.GITHUB_REPOSITORY, + runId: process.env.GITHUB_RUN_ID, + attempt: process.env.GITHUB_RUN_ATTEMPT, + token: process.env.GH_TOKEN, + }) } else if (command === 'merge' && args.length === 4) { const [cargo, npm, binary, output] = args writeFileSync( diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index 5a15b791b9..efbbb20516 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { createHash } from 'node:crypto' +import { gzipSync, gunzipSync } from 'node:zlib' import { execFileSync, spawnSync } from 'node:child_process' import { completeSpdx, checkReleaseFormat } from './phase1.mjs' import { test } from 'node:test' @@ -13,6 +14,7 @@ import { import { join } from 'node:path' import { tmpdir } from 'node:os' import * as fs from 'node:fs' +import vm from 'node:vm' import { fileURLToPath } from 'node:url' import { isDeepStrictEqual } from 'node:util' import { @@ -22,6 +24,607 @@ import { sentinels, } from './phase1.mjs' import { publish, releaseAssets, releaseNotes } from './publish.mjs' +import { + ordinaryApi, + ordinaryDownload, + acquireOrdinaryTransfers, +} from './phase1.mjs' + +test('ordinary transport preserves raw metadata and uses only the fixed authenticated GET', async () => { + const calls = [] + const bytes = '{"total_count":0,"jobs":[],"same":1,"same":2}' + const fetched = await ordinaryApi( + '/repos/example/release/actions/runs/17/attempts/2/jobs?per_page=100', + 'fictional-token', + async (url, options) => { + calls.push({ url, options }) + return new Response(bytes) + } + ) + assert.equal(fetched.toString(), bytes) + assert.equal(calls.length, 1) + assert.equal(calls[0].options.method, 'GET') + assert.equal(calls[0].options.redirect, 'manual') + assert.equal(calls[0].options.headers.Authorization, 'Bearer fictional-token') + // Assert the fixed metadata origin without permitting arbitrary destinations. + assert.equal(new URL(calls[0].url).origin, 'https://api.github.com') // allow-domain: api.github.com +}) + +test('ordinary transport rejects metadata failures and oversized responses without leaking details', async () => { + let discarded = 0 + await assert.rejects( + ordinaryApi( + '/repos/example/release/actions/runs/17/artifacts?per_page=100', + 'fictional-token', + async () => + new Response( + new ReadableStream({ + cancel() { + discarded++ + }, + }), + { status: 403 } + ) + ) + ) + assert.equal(discarded, 1) + for (const response of [ + new Response('fictional-sensitive-body', { status: 403 }), + new Response('fictional-sensitive-body', { + status: 302, + headers: { location: 'https://example.com/fictional-secret' }, + }), + new Response(Buffer.alloc(1024 * 1024 + 1)), + ]) + await assert.rejects( + ordinaryApi( + '/repos/example/release/actions/runs/17/artifacts?per_page=100', + 'fictional-token', + async () => response + ), + (error) => + !/fictional-sensitive|fictional-secret|fictional-token/.test( + error.message + ) + ) + let called = false + await assert.rejects( + ordinaryApi( + '/repos/example/release/releases', + 'fictional-token', + async () => { + called = true + } + ) + ) + assert.equal(called, false) + for (const path of [ + '/repos/../release/actions/runs/17/artifacts?per_page=100', + '/repos/example/../actions/runs/17/artifacts?per_page=100', + ]) { + await assert.rejects( + ordinaryApi(path, 'fictional-token', async () => { + called = true + }) + ) + assert.equal(called, false) + } + let thrown = 0 + await assert.rejects( + ordinaryApi( + '/repos/example/release/actions/runs/17/artifacts?per_page=100', + 'fictional-token', + async () => { + thrown++ + throw Error('https://example.com/fictional-secret fictional-token') + }, + undefined, + async () => {} + ), + (error) => !/fictional-secret|fictional-token/.test(error.message) + ) + assert.equal(thrown, 3, 'should retry a network error at most twice') +}) + +test('ordinary transport retries only transient metadata failures inside its bound', async () => { + const path = '/repos/example/release/actions/runs/17/artifacts?per_page=100' + const waits = [] + const wait = async (milliseconds) => { + waits.push(milliseconds) + } + for (const status of [503, 429]) { + let calls = 0 + waits.length = 0 + const fetched = await ordinaryApi( + path, + 'fictional-token', + async () => + ++calls === 1 + ? new Response('busy', { status }) + : new Response('{"total_count":0}'), + undefined, + wait + ) + assert.equal(fetched.toString(), '{"total_count":0}') + assert.equal(calls, 2, `should retry one ${status}`) + assert.deepEqual(waits, [1_000], 'should back off before the retry') + } + let calls = 0 + await assert.rejects( + ordinaryApi( + path, + 'fictional-token', + async () => { + calls++ + return new Response('busy', { status: 502 }) + }, + undefined, + wait + ), + /HTTP 502/ + ) + assert.equal(calls, 3, 'should stop after three attempts') + calls = 0 + await assert.rejects( + ordinaryApi( + path, + 'fictional-token', + async () => { + calls++ + return new Response('missing', { status: 404 }) + }, + undefined, + wait + ), + /HTTP 404/ + ) + assert.equal(calls, 1, 'should never retry a non-transient status') + const deadline = AbortSignal.abort() + calls = 0 + await assert.rejects( + ordinaryApi( + path, + 'fictional-token', + async () => { + calls++ + return new Response('{}') + }, + deadline, + wait + ) + ) + assert.equal(calls, 0, 'should not start a request after the shared deadline') +}) + +test('ordinary archive retries request a fresh redirect for each attempt', async () => { + const directory = fs.realpathSync( + mkdtempSync(join(tmpdir(), 'ordinary-transport-')) + ) + const bytes = Buffer.from('fictional archive bytes') + const archive = { + id: 41, + size: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + file: 'canonical.zip', + } + const calls = [] + try { + await ordinaryDownload( + { + repository: 'example/release', + archive, + token: 'fictional-token', + directory, + }, + async (url) => { + calls.push(url) + if (url.endsWith('/zip')) + return new Response(null, { + status: 302, + headers: { + location: `https://example.com/archive?attempt=${calls.length}`, + }, + }) + return calls.length === 2 + ? new Response('busy', { status: 503 }) + : new Response(bytes) + }, + async () => {} + ) + assert.deepEqual(readFileSync(join(directory, archive.file)), bytes) + assert.equal(calls.length, 4, 'should retry the redirect and the blob') + assert.match(calls[0], /\/zip$/) + assert.match(calls[2], /\/zip$/) + assert.notEqual(calls[1], calls[3], 'should follow the fresh redirect') + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test('ordinary archive retries only transient API and blob statuses', async () => { + const bytes = Buffer.from('fictional archive bytes') + for (const [apiStatus, blobStatus, expected] of [ + [503, 200, 3], + [404, 200, 1], + [302, 404, 2], + ]) { + const directory = fs.realpathSync( + mkdtempSync(join(tmpdir(), 'ordinary-transport-')) + ) + let calls = 0 + try { + const download = ordinaryDownload( + { + repository: 'example/release', + archive: { + id: 41, + size: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + file: 'canonical.zip', + }, + token: 'fictional-token', + directory, + }, + async (url) => { + calls++ + if (!url.endsWith('/zip')) + return new Response(blobStatus === 200 ? bytes : 'missing', { + status: blobStatus, + }) + return calls === 1 && apiStatus !== 302 + ? new Response('busy', { status: apiStatus }) + : new Response(null, { + status: 302, + headers: { location: 'https://example.com/archive' }, + }) + }, + async () => {} + ) + if (apiStatus === 503) await download + else await assert.rejects(download) + assert.equal(calls, expected, `${apiStatus}/${blobStatus}`) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + } +}) + +test('ordinary retry backoff stops at the shared deadline', async () => { + let calls = 0 + await assert.rejects( + ordinaryApi( + '/repos/example/release/actions/runs/17/artifacts?per_page=100', + 'fictional-token', + async () => { + calls++ + return new Response('busy', { status: 503 }) + }, + AbortSignal.timeout(200) + ), + /deadline exhausted/ + ) + assert.equal(calls, 1, 'should not retry after the deadline ends the backoff') +}) + +test('ordinary transport downloads exact archive bytes without forwarding the API token', async () => { + const directory = fs.realpathSync( + mkdtempSync(join(tmpdir(), 'ordinary-transport-')) + ) + const bytes = Buffer.from('fictional archive bytes') + const archive = { + id: 41, + size: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + file: 'canonical.zip', + } + const calls = [] + try { + await ordinaryDownload( + { + repository: 'example/release', + archive, + token: 'fictional-token', + directory, + }, + async (url, options) => { + calls.push({ url, options }) + return calls.length === 1 + ? new Response(null, { + status: 302, + headers: { + location: 'https://example.com/archive?fictional-secret', + }, + }) + : new Response(bytes) + } + ) + assert.deepEqual(readFileSync(join(directory, archive.file)), bytes) + assert.equal(calls.length, 2) + assert.match(calls[0].url, /\/actions\/artifacts\/41\/zip$/) + assert.equal( + calls[0].options.headers.Authorization, + 'Bearer fictional-token' + ) + assert.equal(calls[1].options.headers?.Authorization, undefined) + assert.equal(calls[1].options.redirect, 'error') + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test('ordinary transport rejects wrong archive hashes and reused output paths', async () => { + const directory = fs.realpathSync( + mkdtempSync(join(tmpdir(), 'ordinary-transport-')) + ) + const archive = { + id: 41, + size: 3, + sha256: 'a'.repeat(64), + file: 'canonical-observation.zip', + } + let calls = 0 + const fetcher = async () => + ++calls % 2 === 1 + ? new Response(null, { + status: 302, + headers: { location: 'https://example.com/archive?fictional-secret' }, + }) + : new Response('bad') + try { + await assert.rejects( + ordinaryDownload( + { + repository: 'example/release', + archive, + token: 'fictional-token', + directory, + }, + fetcher + ), + /archive/ + ) + writeFileSync(join(directory, archive.file), 'retained') + await assert.rejects( + ordinaryDownload( + { + repository: 'example/release', + archive, + token: 'fictional-token', + directory, + }, + fetcher + ) + ) + assert.equal( + readFileSync(join(directory, archive.file), 'utf8'), + 'retained' + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test('ordinary transport stops before ZIP requests when current-run binding fails', async () => { + const root = fs.realpathSync( + mkdtempSync(join(tmpdir(), 'ordinary-transport-')) + ) + const input = join(root, 'input') + mkdirSync(input) + const calls = [] + try { + await assert.rejects( + acquireOrdinaryTransfers( + { + producer: '/fictional/producer', + input, + archives: join(root, 'archives'), + repository: 'example/release', + runId: '17', + attempt: '2', + token: 'fictional-token', + }, + async (url) => { + calls.push(url) + return new Response('{"total_count":0}') + }, + () => { + assert.equal( + readFileSync(join(input, 'jobs.json'), 'utf8'), + '{"total_count":0}' + ) + assert.equal( + readFileSync(join(input, 'artifacts.json'), 'utf8'), + '{"total_count":0}' + ) + throw Error('rejected current-run metadata') + } + ), + /current-run/ + ) + assert.equal(calls.length, 2) + assert.ok(calls.every((url) => !url.endsWith('/zip'))) + assert.equal(fs.existsSync(join(root, 'archives')), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('ordinary transport rejects unsafe redirects before any blob request', async () => { + for (const location of [ + 'http://example.com/archive', + 'https://user:fictional-secret@example.com/archive', + 'https://example.com:444/archive', + 'not-a-url', + ]) { + const root = fs.realpathSync( + mkdtempSync(join(tmpdir(), 'ordinary-transport-')) + ) + let calls = 0 + try { + await assert.rejects( + ordinaryDownload( + { + repository: 'example/release', + token: 'fictional-token', + directory: root, + archive: { + id: 41, + file: 'canonical.zip', + size: 1, + sha256: 'a'.repeat(64), + }, + }, + async () => { + calls++ + return new Response(null, { status: 302, headers: { location } }) + } + ), + (error) => !error.message.includes('fictional-secret') + ) + assert.equal(calls, 1) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +}) + +test('ordinary transport enforces observation and body bounds and aborts before fetching', async () => { + let oversizedCalled = false + await assert.rejects( + ordinaryDownload( + { + repository: 'example/release', + token: 'fictional-token', + directory: '/unused', + archive: { + id: 41, + file: 'canonical-observation.zip', + size: 2 * 1024 * 1024 + 1, + sha256: 'a'.repeat(64), + }, + }, + async () => { + oversizedCalled = true + } + ) + ) + assert.equal(oversizedCalled, false) + for (const body of ['short', 'longer-than-the-bound']) { + const root = fs.realpathSync( + mkdtempSync(join(tmpdir(), 'ordinary-transport-')) + ) + let calls = 0 + try { + await assert.rejects( + ordinaryDownload( + { + repository: 'example/release', + token: 'fictional-token', + directory: root, + archive: { + id: 41, + file: 'canonical-observation.zip', + size: 8, + sha256: 'a'.repeat(64), + }, + }, + async () => + ++calls === 1 + ? new Response(null, { + status: 302, + headers: { location: 'https://example.com/archive' }, + }) + : new Response(body) + ) + ) + assert.equal(calls, 2) + assert.ok(fs.statSync(join(root, 'canonical-observation.zip')).size <= 8) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + let called = false + await assert.rejects( + ordinaryApi( + '/repos/example/release/actions/runs/17/artifacts?per_page=100', + 'fictional-token', + async () => { + called = true + }, + AbortSignal.abort() + ) + ) + assert.equal(called, false) +}) + +test('ordinary transport acquires exactly the four Rust-bound archives in fixed order', async () => { + const root = fs.realpathSync( + mkdtempSync(join(tmpdir(), 'ordinary-transport-')) + ) + const input = join(root, 'input'), + archives = join(root, 'archives') + mkdirSync(input) + const bytes = Buffer.from('fictional bound ZIP bytes') + const names = [ + 'canonical.zip', + 'canonical-observation.zip', + 'reproduction.zip', + 'reproduction-observation.zip', + ] + let planned = false, + calls = 0 + try { + await acquireOrdinaryTransfers( + { + producer: '/fictional/producer', + input, + archives, + repository: 'example/release', + runId: '17', + attempt: '2', + token: 'fictional-token', + }, + async (url, options) => { + calls++ + assert.equal(options.method, 'GET') + if (url.endsWith('per_page=100')) + return new Response('{"total_count":0}') + assert.equal(planned, true) + // The mock distinguishes the fixed authenticated API from blob reads. + const apiOrigin = 'https://api.github.com/' // allow-domain: api.github.com + if (url.startsWith(apiOrigin)) + return new Response(null, { + status: 302, + headers: { location: 'https://example.com/archive' }, + }) + assert.equal(options.headers?.Authorization, undefined) + return new Response(bytes) + }, + (producer, metadataRoot) => { + assert.equal(producer, '/fictional/producer') + assert.equal(metadataRoot, input) + assert.equal(fs.existsSync(join(input, 'artifacts.json')), true) + planned = true + return { + version: 1, + archives: names.map((file, index) => ({ + file, + id: index + 41, + size: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + })), + } + } + ) + assert.equal(calls, 10) + assert.deepEqual(fs.readdirSync(archives).sort(), [...names].sort()) + for (const name of names) + assert.deepEqual(readFileSync(join(archives, name)), bytes) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + test('rejects every planted operator value, including inside binary data', () => { for (const marker of sentinels) { assert.throws( @@ -193,7 +796,8 @@ test('signing job shell refuses symlinked, extra, empty and changed inputs', () workflow, "Retain only this run's real bundles and signed-input digests" ) - const asset = 'example-asset' + // Use a cohort asset without a companion; the validator rejects unknown names. + const asset = 'ts-x86_64-unknown-linux-gnu' for (const mode of [ 'success', 'symlink-binary', @@ -229,7 +833,18 @@ test('signing job shell refuses symlinked, extra, empty and changed inputs', () if (mode === 'extra') writeFileSync(join(inputs, 'extra'), 'extra') if (mode === 'missing') rmSync(join(inputs, `${asset}.spdx.json`)) if (mode === 'empty') writeFileSync(join(inputs, asset), '') - const env = { PATH: process.env.PATH, ASSET: asset } + const env = { + PATH: process.env.PATH, + ASSET: asset, + SOURCE_COMMIT: 'a'.repeat(40), + GITHUB_RUN_ID: '17', + GITHUB_RUN_ATTEMPT: '2', + GITHUB_OUTPUT: join(root, 'github-output'), + // The step checks the pinned Node; pin whichever node PATH selects. + NODE_VERSION: execFileSync('node', ['--version'], { encoding: 'utf8' }) + .trim() + .slice(1), + } const run = (script, extra = {}) => spawnSync('bash', ['-c', script], { cwd: root, @@ -283,6 +898,299 @@ const assets = releaseAssets // The publisher fixture shadows `execFileSync`; this runs a real child process. const realExecFileSync = execFileSync const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') + +test('isolated signing accepts only bound 4/2 input sets and retains exact provenance subjects', () => { + const workflow = readFileSync('.github/workflows/release-build.yml', 'utf8') + const match = workflow.match( + / \/\/ BEGIN SIGNING INPUTS\n([\s\S]*?) \/\/ END SIGNING INPUTS/ + ) + assert.ok(match, 'should exercise the actual inline signer validation') + const script = match[1] + .split('\n') + .map((line) => line.slice(10)) + .join('\n') + const canonical = (value) => + Array.isArray(value) + ? value.map(canonical) + : value && typeof value === 'object' + ? Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonical(value[key])]) + ) + : value + for (const asset of assets) + for (const mode of asset === 'trusted-server-fastly.wasm' + ? [ + 'success', + 'missing', + 'extra', + 'source', + 'run', + 'attempt', + 'failed', + 'profile', + 'qualified', + 'binary', + 'spdx', + 'duplicate-key', + 'earlier-attempt', + 'zero-attempt', + 'version', + 'asset', + 'target', + 'variant', + 'reproduction-binary', + 'same-job', + 'job-id', + 'fractional-job-id', + 'size', + 'build-binary', + 'unsorted', + 'unknown-asset', + 'bad-source', + 'bad-run', + 'bad-attempt', + 'oversized-binary', + 'oversized-companion', + 'oversized-spdx', + 'fractional-attempt', + 'missing-package', + 'changed-package', + 'package-size', + 'package-digest', + 'package-reproduction', + 'package-manifest', + 'build-package', + ] + : ['success', 'extra']) { + const root = mkdtempSync(join(tmpdir(), 'ordinary-sign-')) + try { + mkdirSync(join(root, 'release-assets')) + const binary = Buffer.from(`fixture ${asset}`), + spdx = Buffer.from('{}') + writeFileSync(join(root, 'release-assets', asset), binary) + writeFileSync(join(root, 'release-assets', `${asset}.spdx.json`), spdx) + if (asset === 'trusted-server-fastly.wasm') { + const packageBytes = Buffer.from('fixture deployable package') + writeFileSync( + join(root, 'release-assets', 'trusted-server-fastly.tar.gz'), + packageBytes + ) + mkdirSync(join(root, 'comparison')) + const receipt = { + version: 1, + profile: 'ordinary-release-fastly-byte-equality-v1', + release_qualification: false, + source_commit: 'a'.repeat(40), + asset, + target: 'wasm32-wasip1', + variant: 'default', + outcome: 'matched', + package: { + asset: 'trusted-server-fastly.tar.gz', + size: packageBytes.length, + canonical_sha256: hash(packageBytes), + reproduction_sha256: hash(packageBytes), + manifest_sha256: hash( + Buffer.from( + 'manifest_version = 3\nname = "trusted-server-fastly"\n' + ) + ), + }, + canonical_sha256: hash(binary), + reproduction_sha256: hash(binary), + canonical: { + run_id: 17, + run_attempt: 2, + job_id: 101, + size: binary.length, + sha256: hash(binary), + spdx_sha256: hash(spdx), + package: { + size: packageBytes.length, + sha256: hash(packageBytes), + }, + }, + reproduction: { + run_id: 17, + run_attempt: 2, + job_id: 102, + package: { + size: packageBytes.length, + sha256: hash(packageBytes), + }, + size: binary.length, + sha256: hash(binary), + }, + } + if (mode === 'missing-package') + fs.unlinkSync( + join(root, 'release-assets', 'trusted-server-fastly.tar.gz') + ) + if (mode === 'changed-package') + writeFileSync( + join(root, 'release-assets', 'trusted-server-fastly.tar.gz'), + 'changed' + ) + if (mode === 'package-size') receipt.package.size++ + if (mode === 'package-digest') + receipt.package.canonical_sha256 = '0'.repeat(64) + if (mode === 'package-reproduction') + receipt.package.reproduction_sha256 = '0'.repeat(64) + if (mode === 'package-manifest') + receipt.package.manifest_sha256 = '0'.repeat(64) + if (mode === 'build-package') + receipt.reproduction.package.sha256 = '0'.repeat(64) + if (mode === 'source') receipt.source_commit = 'b'.repeat(40) + if (mode === 'run') receipt.canonical.run_id = 18 + // A later attempt cannot have sealed this run's receipt; an earlier + // one can, when "Re-run failed jobs" retries only a signing leg. + if (mode === 'attempt') receipt.reproduction.run_attempt = 3 + if (mode === 'earlier-attempt') { + receipt.canonical.run_attempt = 1 + receipt.reproduction.run_attempt = 1 + } + if (mode === 'zero-attempt') receipt.canonical.run_attempt = 0 + if (mode === 'fractional-attempt') receipt.canonical.run_attempt = 1.5 + if (mode === 'failed') receipt.outcome = 'mismatched' + if (mode === 'profile') receipt.profile = 'controlled-recipe' + if (mode === 'qualified') receipt.release_qualification = true + if (mode === 'binary') receipt.canonical_sha256 = '0'.repeat(64) + if (mode === 'spdx') receipt.canonical.spdx_sha256 = '0'.repeat(64) + if (mode === 'version') receipt.version = 2 + if (mode === 'asset') receipt.asset = 'ts-x86_64-unknown-linux-gnu' + if (mode === 'target') receipt.target = 'wasm32-unknown-unknown' + if (mode === 'variant') receipt.variant = 'example' + if (mode === 'reproduction-binary') + receipt.reproduction_sha256 = '0'.repeat(64) + if (mode === 'same-job') receipt.reproduction.job_id = 101 + if (mode === 'job-id') receipt.canonical.job_id = 0 + if (mode === 'fractional-job-id') receipt.reproduction.job_id = 102.5 + if (mode === 'size') receipt.reproduction.size += 1 + if (mode === 'build-binary') + receipt.reproduction.sha256 = '0'.repeat(64) + let text = + JSON.stringify(mode === 'unsorted' ? receipt : canonical(receipt)) + + '\n' + if (mode === 'duplicate-key') { + assert.ok(text.startsWith('{')) + text = '{"version":0,' + text.slice(1) + } + if (mode !== 'missing') + writeFileSync( + join(root, 'comparison', `${asset}.rebuild-check.json`), + text + ) + if (mode === 'extra') + writeFileSync(join(root, 'comparison', 'unexpected'), 'extra') + // Sparse files reach the size bound without allocating it. + if (mode === 'oversized-binary') + fs.truncateSync( + join(root, 'release-assets', asset), + 512 * 1024 * 1024 + 1 + ) + if (mode === 'oversized-companion') + fs.truncateSync( + join(root, 'comparison', `${asset}.rebuild-check.json`), + 32 * 1024 * 1024 + 1 + ) + if (mode === 'oversized-spdx') + fs.truncateSync( + join(root, 'release-assets', `${asset}.spdx.json`), + 32 * 1024 * 1024 + 1 + ) + } else if (mode === 'extra') + writeFileSync(join(root, 'release-assets', 'unexpected'), 'extra') + const local = (path) => join(root, path) + const context = { + Buffer, + process: { + env: { + ASSET: mode === 'unknown-asset' ? 'ts-example-unknown' : asset, + SOURCE_COMMIT: + mode === 'bad-source' ? 'A'.repeat(40) : 'a'.repeat(40), + GITHUB_RUN_ID: mode === 'bad-run' ? '017' : '17', + GITHUB_RUN_ATTEMPT: mode === 'bad-attempt' ? '2x' : '2', + GITHUB_OUTPUT: 'outputs', + }, + }, + require(name) { + if (name === 'node:crypto') return { createHash } + if (name === 'node:fs') + return { + readdirSync: (path) => fs.readdirSync(local(path)), + lstatSync: (path) => fs.lstatSync(local(path)), + readFileSync: (path) => fs.readFileSync(local(path)), + writeFileSync: (path, ...args) => + fs.writeFileSync(local(path), ...args), + appendFileSync: (path, ...args) => + fs.appendFileSync(local(path), ...args), + } + throw Error(`Unexpected signing module: ${name}`) + }, + } + const execute = () => vm.runInNewContext(script, context) + if (mode !== 'success' && mode !== 'earlier-attempt') { + // These modes must stop at their own guard, not a later check. + const reason = { + 'unknown-asset': /Invalid signing identity/, + 'bad-source': /Invalid signing identity/, + 'bad-run': /Invalid signing identity/, + 'bad-attempt': /Invalid signing identity/, + 'oversized-binary': /Invalid signing input/, + 'oversized-companion': /Invalid signing input/, + 'oversized-spdx': /Invalid signing input/, + }[mode] + assert.throws(execute, reason, `${asset}: ${mode}`) + assert.equal( + fs.existsSync(join(root, 'inputs.sha256')), + false, + 'should not seal failed inputs' + ) + continue + } + execute() + const files = [ + asset, + `${asset}.spdx.json`, + ...(asset === 'trusted-server-fastly.wasm' + ? ['trusted-server-fastly.tar.gz', `${asset}.rebuild-check.json`] + : []), + ] + assert.equal( + readFileSync(join(root, 'inputs.sha256'), 'utf8'), + files + .map( + (file) => + `${hash(readFileSync(join(root, 'release-assets', file)))} ${file}\n` + ) + .join('') + ) + const output = readFileSync(join(root, 'outputs'), 'utf8') + assert.equal( + output.match( + /sbom_subjects< { const block = markdown.match( /\n\n```bash\n([\s\S]*?)\n```\n\n/ @@ -328,6 +1236,8 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an 'missing-tag', 'malformed-tag', 'bundle-verification-failure', + 'package-provenance-failure', + 'package-sbom-failure', 'bundle-sbom-mismatch', 'bundle-sbom-duplicate', 'identity-late', @@ -345,6 +1255,13 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an 'extra', 'changed', 'empty-bundle', + 'missing-companion', + 'changed-companion', + 'extra-companion', + 'unsigned-companion', + 'changed-package', + 'missing-package', + 'unsigned-package', 'symlink-input', 'signed-extra', 'created-wrong-tag', @@ -431,6 +1348,37 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an ) entries.push(`${hash(bytes)} ${asset}${suffix}\n`) } + if (asset === 'trusted-server-fastly.wasm') { + const packageBytes = Buffer.from('fixture deployable package') + writeFileSync( + join(build, 'trusted-server-fastly.tar.gz'), + packageBytes + ) + if (mode !== 'unsigned-package') + entries.splice( + 2, + 0, + `${hash(packageBytes)} trusted-server-fastly.tar.gz\n` + ) + if (mode === 'changed-package') + writeFileSync( + join(build, 'trusted-server-fastly.tar.gz'), + 'changed' + ) + if (mode === 'missing-package') + fs.unlinkSync(join(build, 'trusted-server-fastly.tar.gz')) + const companion = `${asset}.rebuild-check.json`, + bytes = Buffer.from('fixture signed ordinary rebuild check') + writeFileSync(join(signed, companion), bytes) + if (mode !== 'unsigned-companion') + entries.splice(3, 0, `${hash(bytes)} ${companion}\n`) + if (mode === 'changed-companion') + writeFileSync(join(signed, companion), 'changed') + if (mode === 'missing-companion') + fs.unlinkSync(join(signed, companion)) + if (mode === 'extra-companion') + writeFileSync(join(signed, 'unexpected'), 'extra') + } writeFileSync(join(signed, 'SHA256SUMS'), entries.join('')) } if (mode === 'missing') @@ -653,8 +1601,19 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an calls.filter( ([kind, call]) => kind === 'gh' && call[1][0] === 'attestation' ).length - 1 - const name = assets[Math.floor(index / 2)] - const kind = index % 2 ? 'sbom' : 'provenance' + // Two predicates per binary; the Fastly provenance also names its + // rebuild check. + const [name, subject, kind] = assets.flatMap((asset) => [ + [asset, asset, 'provenance'], + [asset, asset, 'sbom'], + ...(asset === 'trusted-server-fastly.wasm' + ? [ + [asset, `${asset}.rebuild-check.json`, 'provenance'], + [asset, 'trusted-server-fastly.tar.gz', 'provenance'], + [asset, 'trusted-server-fastly.tar.gz', 'sbom'], + ] + : []), + ])[index] assert.deepEqual( args, [ @@ -662,7 +1621,7 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an [ 'attestation', 'verify', - join(root, 'release-assets', name), + join(root, 'release-assets', subject), '--repo', 'example/project', '--bundle', @@ -697,6 +1656,11 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an ) if (mode === 'bundle-verification-failure' && index === 3) throw Error('Signature verification failed') + if ( + subject === 'trusted-server-fastly.tar.gz' && + mode === `package-${kind}-failure` + ) + throw Error('Signature verification failed') // A provenance result carries an SLSA predicate, never the SPDX. const result = { verificationResult: { @@ -801,6 +1765,8 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an 'non-semver-tag': /Invalid repository or tag/, 'invalid-repository': /Invalid repository or tag/, 'invalid-source': /Invalid source commit/, + 'package-provenance-failure': /Signature verification failed/, + 'package-sbom-failure': /Signature verification failed/, 'empty-bundle': /Invalid file/, 'upload-error': /Asset upload failed/, 'build-metadata-tag': /Invalid repository or tag/, @@ -867,9 +1833,18 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an 'extra', 'changed', 'empty-bundle', + 'missing-companion', + 'changed-companion', + 'extra-companion', + 'unsigned-companion', + 'changed-package', + 'missing-package', + 'unsigned-package', 'symlink-input', 'signed-extra', 'bundle-verification-failure', + 'package-provenance-failure', + 'package-sbom-failure', 'bundle-sbom-mismatch', 'bundle-sbom-duplicate', ].includes(mode) @@ -890,7 +1865,8 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an ), 'should read the second release page' ) - const bundleChecks = releaseAssets.length * 2 + // Two predicates per binary, plus the Fastly rebuild check. + const bundleChecks = releaseAssets.length * 2 + 3 if (mode === 'api-error' || mode === 'upload-error') assert.equal( calls.length, @@ -937,7 +1913,8 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an mode ) if (successful) { - assert.equal(uploaded.length, assets.length * 4 + 1) + // Four files per binary, the Fastly rebuild check and SHA256SUMS. + assert.equal(uploaded.length, assets.length * 4 + 3) // Two-space lines are what `shasum -a 256 --check` accepts. assert.equal( uploaded.find((item) => item.name === 'SHA256SUMS').bytes.toString(), @@ -1018,6 +1995,13 @@ test('acquires the locked workspace graph before offline SBOM resolution', () => acquisition < script.indexOf('CARGO_NET_OFFLINE=true'), 'should acquire before offline generation' ) + // The Fastly ordinary capture reads the locked graph this step writes. + const metadata = script.match(/^ {4}ORDINARY_METADATA="([^"]+)" \\$/m)?.[1] + assert.ok(metadata, 'should pass the metadata file to the ordinary capture') + assert.ok( + script.slice(acquisition).split('\n')[0].endsWith(` > "${metadata}"`), + 'should write the metadata the ordinary capture reads' + ) }) // SPDX conversion retains the binary root and conservative package inventory. const binary = Buffer.from('fictional binary') @@ -1396,12 +2380,43 @@ test('workflows build, sign and publish exactly the release assets and restrict const names = [...releaseAssets].sort() const build = readFileSync('.github/workflows/release-build.yml', 'utf8') const release = readFileSync('.github/workflows/release.yml', 'utf8') + const canonical = build + .split('\n fastly-canonical:')[1] + ?.split(/\n [a-z][\w-]*:\n/)[0] + const fastly = canonical?.match(/^ RELEASE_ASSET: (\S+)/m)?.[1] assert.deepEqual( - [...build.matchAll(/^ asset: (\S+)/gm)].map((m) => m[1]).sort(), + [...build.matchAll(/^ asset: (\S+)/gm)] + .map((m) => m[1]) + .concat(fastly ? [fastly] : []) + .sort(), names ) const sign = build.split('\n sign:')[1] - for (const name of names) assert.equal(sign.split(name).length - 1, 1) + const matrix = sign + .split('\n steps:')[0] + .match(/\n asset:\s*\[([\s\S]*?)\]/)[1] + assert.deepEqual( + matrix + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + .sort(), + names, + 'should sign precisely the cohort' + ) + const allowed = sign.match(/const names = (\[[^;]+\]);/)[1] + assert.deepEqual( + Array.from(vm.runInNewContext(allowed)).sort(), + names, + 'should validate the same exact input cohort' + ) + assert.deepEqual( + JSON.parse(readFileSync('scripts/release/release-cohort.json', 'utf8')) + .artifacts.map((artifact) => artifact.name) + .sort(), + names, + 'should keep the closed cohort selection on the published binaries' + ) for (const prefix of ['release-', 'signed-']) assert.deepEqual( [ @@ -1465,15 +2480,16 @@ test('workflows build, sign and publish exactly the release assets and restrict 'SOURCE_COMMIT', ] ) - // Provenance covers the binary and its SPDX document. + // Provenance covers the validated subjects: the binary, its SPDX document + // and, for the Fastly leg, the rebuild-check companion. assert.match( sign, - /subject-path: \|\n {12}release-assets\/\$\{\{ matrix\.asset \}\}\n {12}release-assets\/\$\{\{ matrix\.asset \}\}\.spdx\.json\n/ + /subject-path: \$\{\{ steps\.inputs\.outputs\.subjects \}\}\n/ ) // The SBOM attestation binds the SPDX document to the binary. assert.match( sign, - /subject-path: release-assets\/\$\{\{ matrix\.asset \}\}\n {10}sbom-path: release-assets\/\$\{\{ matrix\.asset \}\}\.spdx\.json\n/ + /subject-path: \$\{\{ steps\.inputs\.outputs\.sbom_subjects \}\}\n {10}sbom-path: release-assets\/\$\{\{ matrix\.asset \}\}\.spdx\.json\n/ ) // The branch check needs every branch's history. assert.match( @@ -1499,6 +2515,269 @@ test('workflows build, sign and publish exactly the release assets and restrict 'write', ]) }) + +test('ordinary Fastly gate binds dedicated A and B uploads before isolated signing', () => { + const build = readFileSync('scripts/release/phase1-build.sh', 'utf8') + assert.match(build, /ordinary-rustflags/) + assert.match(build, /cargo --config "\$ordinary_rustflags" build/) + assert.ok( + build.indexOf('ordinary-rustflags') < build.indexOf('npm --prefix'), + 'should seal arguments before dependency preparation and compilation' + ) + assert.match(build, /rustflags: env\.ORDINARY_RUSTFLAGS/) + const workflow = readFileSync('.github/workflows/release-build.yml', 'utf8') + const caller = readFileSync('.github/workflows/release.yml', 'utf8') + const job = (name) => + workflow.split(`\n ${name}:`)[1]?.split(/\n [a-z][\w-]*:\n/)[0] + const canonical = job('fastly-canonical'), + reproduction = job('fastly-reproduction'), + comparison = job('fastly-comparison') + assert.ok( + canonical && reproduction && comparison, + 'should run two dedicated builds and a comparison' + ) + assert.match(canonical, /name: Build trusted-server-fastly\.wasm/) + assert.match(reproduction, /name: Reproduce trusted-server-fastly\.wasm/) + assert.match(reproduction, /needs:.*fastly-canonical/) + for (const [role, body] of [ + ['canonical', canonical], + ['reproduction', reproduction], + ]) { + assert.doesNotMatch(body, /id-token|attestations:|contents: write/) + assert.match(body, new RegExp(`path: ordinary-${role}-source`)) + assert.match(body, new RegExp(`Upload ${role} release artifact`)) + assert.match(body, new RegExp(`Upload ${role} observation`)) + for (const output of [ + 'artifact-id', + 'artifact-digest', + 'observation-id', + 'observation-digest', + ]) + assert.match(body, new RegExp(` ${output}:`)) + } + assert.match(comparison, /needs: \[fastly-canonical, fastly-reproduction\]/) + assert.match(comparison, /actions: read/) + assert.match(comparison, /ref: \$\{\{ github\.sha \}\}/) + assert.match( + comparison, + /test "\$\(git rev-parse HEAD\)" = "\$SOURCE_COMMIT"/ + ) + assert.doesNotMatch(comparison, /id-token|attestations:|contents: write/) + assert.match(comparison, /ordinary-fetch/) + assert.match(comparison, /ordinary-compare/) + assert.match(comparison, /id: comparison/) + // Without a status function GitHub adds success(), so the companion uploads + // only after every earlier step and the comparison itself succeed. + assert.match( + comparison, + /- name: Upload the real Fastly rebuild check\n {8}if: steps\.comparison\.outcome == 'success'\n/ + ) + assert.match(comparison, /if ! env -i/) + assert.doesNotMatch( + comparison, + /(?:test -[ef]|existsSync).*rebuild-check\.json/ + ) + for (const role of ['canonical', 'reproduction']) + for (const output of [ + 'artifact-id', + 'artifact-digest', + 'observation-id', + 'observation-digest', + ]) + assert.ok( + comparison.includes(`needs.fastly-${role}.outputs.${output}`), + 'should carry every direct upload output' + ) + assert.match(job('sign'), /needs: \[checks, build, fastly-comparison\]/) + assert.match(caller.split('\n release:')[0], /actions: read/) +}) + +test('canonical Fastly route verifies the pinned tool offline without credentials or deployment', () => { + const workflow = readFileSync('.github/workflows/release-build.yml', 'utf8') + const canonical = workflow + .split('\n fastly-canonical:')[1] + .split('\n fastly-reproduction:')[0] + const route = readFileSync( + 'scripts/release/validate-fastly-package.sh', + 'utf8' + ) + for (const [role, job] of [ + ['canonical', canonical], + [ + 'reproduction', + workflow + .split('\n fastly-reproduction:')[1] + .split('\n fastly-comparison:')[0], + ], + ]) { + assert.ok( + job.indexOf('bash scripts/release/validate-fastly-package.sh') > 0 && + job.indexOf('bash scripts/release/validate-fastly-package.sh') < + job.indexOf(`- name: Upload ${role} release artifact`), + 'should validate each actual package before upload' + ) + } + assert.match(route, /\.tool-versions/) + assert.match(route, /test "\$fastly_version" = 16\.0\.0/) + assert.match( + route, + /b1b89112f1f1291ff275393779ffdacba8724ac64091bda0fa6000ae06308459/ + ) + assert.match(route, /sha256sum --check/) + assert.ok( + route.indexOf('sha256sum --check') < route.indexOf('unshare --net'), + 'should authenticate the downloaded CLI before any execution' + ) + assert.match(route, /unshare --net -- setpriv/) + assert.match(route, /--bounding-set=-all --no-new-privs/) + assert.match(route, /env -i/) + assert.match(route, /guest_network=\$\(readlink \/proc\/self\/ns\/net\)/) + assert.match(route, /active_interfaces=\$\(ip -o link show up\)/) + assert.doesNotMatch(route, /test[^\n]*\$\((?:ip|readlink) /) + assert.match(route, /XDG_CONFIG_HOME="\$route\/config"/) + assert.doesNotMatch( + route, + /(?:^|\s)HOME=|--token|FASTLY_API_TOKEN|fastly compute deploy|contents: write|id-token/m + ) + for (const command of [ + 'fastly compute pack --wasm-binary ./trusted-server-fastly.wasm', + 'fastly compute validate --package ./pkg/example-app.tar.gz', + 'tar -tzf ./pkg/example-app.tar.gz', + 'cmp trusted-server-fastly.wasm packaged.wasm', + 'cmp fastly.toml packaged.toml', + 'inspect-fastly-package', + 'fastly compute validate --package ./trusted-server-fastly.tar.gz', + ]) + assert.ok(route.includes(command), `should execute ${command}`) + assert.match(route, /set -euxo pipefail/) + assert.match(route, /SUCCESS: pinned Fastly route/) + assert.doesNotMatch( + canonical.split(' steps:')[0], + /write|id-token|attestations:/ + ) +}) + +test('Fastly route diagnostics preserve literal directory headers and reject malformed size fields', () => { + const workflow = readFileSync( + 'scripts/release/validate-fastly-package.sh', + 'utf8' + ) + const match = workflow.match( + /timeout 15 "\$6" --input-type=commonjs <<'HEADERS'\n([\s\S]*?)HEADERS/ + ) + assert.ok(match, 'should inspect the actual bounded diagnostic program') + const script = match[1] + const bytes = Buffer.alloc(3 * 512) + for (const [index, path] of ['example-app', 'example-app/bin'].entries()) { + const header = bytes.subarray(index * 512, (index + 1) * 512) + header.write(path) + header.write('00000000000\0', 124) + header[156] = 53 + } + const records = [] + let compressed = gzipSync(bytes) + const context = { + console: { + log: (label, data) => { + assert.equal(label, 'Fastly raw TAR header:') + records.push(JSON.parse(data)) + }, + }, + require(name) { + if (name === 'node:fs') + return { + lstatSync: () => ({ isFile: () => true, size: compressed.length }), + readFileSync: () => compressed, + } + if (name === 'node:zlib') return { gunzipSync } + throw Error(`Unexpected diagnostic module: ${name}`) + }, + } + vm.runInNewContext(script, { ...context }) + assert.deepEqual( + records, + ['example-app', 'example-app/bin'].map((path) => ({ + path, + type: '5', + size: 0, + prefix: '', + link: '', + nonzero_padding: false, + })) + ) + bytes[64] = 1 + compressed = gzipSync(bytes) + vm.runInNewContext(script, { ...context }) + assert.equal( + records.at(-2).nonzero_padding, + true, + 'should expose hidden nonzero name padding' + ) + bytes[64] = 0 + bytes.write('invalid-size', 124) + compressed = gzipSync(bytes) + assert.throws( + () => vm.runInNewContext(script, { ...context }), + /Unsupported diagnostic size encoding/ + ) + assert.ok( + workflow.indexOf('Fastly raw TAR header:') < + workflow.indexOf( + 'timeout 30 "$2" inspect-fastly-package --package-dir ./pkg' + ), + 'should log literal headers before the unchanged strict inspector' + ) +}) + +test('failed comparison is proven by the Rust fixture and blocks the real sign and publish jobs', () => { + const workflow = readFileSync('.github/workflows/test.yml', 'utf8') + const job = (name, source = workflow) => + source.split(`\n ${name}:`)[1]?.split(/\n [a-z][\w-]*:\n/)[0] + const native = job('test-axum') + assert.match( + native, + /ordinary_compare_mismatch_writes_no_companion -- --exact/ + ) + assert.match(native, /1 passed; 0 failed; 0 ignored;/) + const fixture = readFileSync( + 'crates/trusted-server-release/tests/commands.rs', + 'utf8' + ) + assert.match(fixture, /fn ordinary_compare_mismatch_writes_no_companion\(/) + assert.match( + fixture, + /stderr\(&result\)\.contains\("ordinary final-byte mismatch"\)/ + ) + // A job `if:` without a status function gets GitHub's implicit success(), + // so a failed comparison skips signing and, through the reusable workflow, + // publication. A status function would let either job run after a failure. + const statusFunction = /\b(?:always|failure|cancelled)\(\)/ + const jobCondition = (body) => body.match(/^ {4}if: (.+)$/m)?.[1] ?? '' + const build = readFileSync('.github/workflows/release-build.yml', 'utf8') + const sign = job('sign', build) + assert.match(sign, /needs: \[checks, build, fastly-comparison\]/) + // continue-on-error would turn a failed check, build or comparison into + // success and let signing run after it. + assert.doesNotMatch( + build, + /continue-on-error/, + 'release jobs must fail closed' + ) + assert.doesNotMatch( + jobCondition(sign), + statusFunction, + 'should skip signing whenever the comparison fails' + ) + const caller = readFileSync('.github/workflows/release.yml', 'utf8') + const release = job('release', caller) + assert.match(release, /needs: build\n/) + assert.doesNotMatch( + jobCondition(release), + statusFunction, + 'should skip publication whenever the reusable build fails' + ) +}) + test('release build keeps locked builds, pinned generators and final integrity checks', () => { const script = readFileSync('scripts/release/phase1-build.sh', 'utf8') const commands = script.split('\n').filter((line) => !/^\s*#/.test(line)) @@ -1506,11 +2785,19 @@ test('release build keeps locked builds, pinned generators and final integrity c // set -e, and CI runs this suite on Linux, so each one must exit explicitly. for (const line of commands.filter((line) => line.startsWith('[['))) assert.match(line, /\]\] \|\| \{ .+; exit 1; \}$/, line) - const builds = commands.filter((line) => /\bcargo build\b/.test(line)) + // Each Fastly role builds with its own fresh Cargo home, exported before + // the producer seals the arguments that remap it. + assert.match( + script, + /export CARGO_HOME="\$RUNNER_TEMP\/ordinary-\$ordinary_role-cargo-home"\n\s*\[\[ ! -e "\$CARGO_HOME" \]\] \|\| \{[^\n]+\}\n\s*mkdir "\$CARGO_HOME"\n[\s\S]*ordinary-rustflags/ + ) + const builds = commands.filter((line) => + /\bcargo (?:--config "\$ordinary_rustflags" )?build\b/.test(line) + ) assert.equal( builds.length, - 2, - 'should build in the GNU container and natively' + 3, + 'should build in the GNU container, the ordinary Fastly path and natively' ) for (const line of builds) assert.match(line, /--locked/) // Native binaries drop line-level debug info in both build paths. @@ -1579,10 +2866,21 @@ test('release build keeps locked builds, pinned generators and final integrity c ) // The released bytes must be unchanged since checks ran, and the build must // not have rewritten the lockfiles or manifest. - const tail = commands.filter(Boolean).slice(-2) + const integrity = step( + 'git diff --exit-code -- Cargo.lock crates/trusted-server-js/lib/package-lock.json edgezero.toml' + ) + assert.equal( + commands[integrity - 1], + 'shasum -a 256 --check "$RUNNER_TEMP/binary.sha256"' + ) + // The ordinary Fastly observation re-checks the bytes after measuring them. + const tail = commands + .filter(Boolean) + .slice(-2) + .map((line) => line.trim()) assert.deepEqual(tail, [ 'shasum -a 256 --check "$RUNNER_TEMP/binary.sha256"', - 'git diff --exit-code -- Cargo.lock crates/trusted-server-js/lib/package-lock.json edgezero.toml', + 'fi', ]) }) test('release preflight rejects inherited controls in an isolated script fixture', () => { @@ -1961,6 +3259,35 @@ test('build checks refuse a tagged commit that is on no branch', () => { ) } }) +test('signing step refuses a Node other than the pinned version before validating', () => { + const workflow = readFileSync('.github/workflows/release-build.yml', 'utf8') + const step = 'Validate signing inputs and retain their digests' + assert.match( + workflow.split(`- name: ${step}`)[1], + /\n {10}NODE_VERSION: \$\{\{ needs\.checks\.outputs\.node \}\}\n/, + 'should compare against the Node version the checks job pinned' + ) + for (const [installed, status] of [ + ['v24.20.0', 0], + ['v20.20.2', 1], + ]) { + const result = runStep( + workflow, + step, + { + node: `if [ "$1" = --version ]; then echo ${installed}; else cat >/dev/null; echo validated; fi`, + }, + { NODE_VERSION: '24.20.0' } + ) + assert.equal(result.status, status, `${installed}: ${result.stderr}`) + assert.equal( + result.stdout.includes('validated'), + status === 0, + `${installed}: should validate signing inputs only on the pinned Node` + ) + } +}) + test('publisher step refuses a Node other than the pinned version before publishing', () => { const release = readFileSync('.github/workflows/release.yml', 'utf8') const step = 'Verify handoff, create once, publish by release ID and verify' diff --git a/scripts/release/publish.mjs b/scripts/release/publish.mjs index 0c8a10d298..38113809c7 100644 --- a/scripts/release/publish.mjs +++ b/scripts/release/publish.mjs @@ -93,15 +93,26 @@ export async function publish({ for (const name of releaseAssets) { const build = path('build', name) const signed = path('signed', name) - const inputs = [name, `${name}.spdx.json`] + const inputs = [ + name, + `${name}.spdx.json`, + ...(name === 'trusted-server-fastly.wasm' + ? ['trusted-server-fastly.tar.gz'] + : []), + ] const bundles = [ `${name}.provenance.sigstore.json`, `${name}.sbom.sigstore.json`, ] + const companions = + name === 'trusted-server-fastly.wasm' + ? [`${name}.rebuild-check.json`] + : [] exact(build, inputs) - exact(signed, [...bundles, 'SHA256SUMS']) + exact(signed, [...companions, ...bundles, 'SHA256SUMS']) const receipt = [ ...inputs.map((file) => [build, file]), + ...companions.map((file) => [signed, file]), ...bundles.map((file) => [signed, file]), ] .map(([dir, file]) => { @@ -127,53 +138,76 @@ export async function publish({ // Verify every signing bundle before anything is created: a defective bundle // would otherwise become a permanent part of the immutable release. Nothing // exists yet, so a failure here is safe to rerun. - for (const name of releaseAssets) - for (const [kind, predicate] of [ - ['provenance', 'https://slsa.dev/provenance/v1'], - ['sbom', 'https://spdx.dev/Document/v2.3'], - ]) { - const verified = JSON.parse( - execFileSync( - 'gh', + // The Fastly provenance also names the rebuild-check companion as a subject. + const checks = releaseAssets.flatMap((name) => [ + [name, name, 'provenance', 'https://slsa.dev/provenance/v1'], + [name, name, 'sbom', 'https://spdx.dev/Document/v2.3'], + ...(name === 'trusted-server-fastly.wasm' + ? [ [ - 'attestation', - 'verify', - path('release-assets', name), - '--repo', - repo, - '--bundle', - path('release-assets', `${name}.${kind}.sigstore.json`), - '--source-ref', - `refs/tags/${tag}`, - '--source-digest', - source, - '--signer-digest', - source, - '--deny-self-hosted-runners', - '--predicate-type', - predicate, - '--signer-workflow', - `${repo}/.github/workflows/release-build.yml`, - '--format', - 'json', + name, + `${name}.rebuild-check.json`, + 'provenance', + 'https://slsa.dev/provenance/v1', ], - // The SBOM result embeds the whole SPDX document, which exceeds - // Node's 1 MiB default output buffer. - { timeout: 60000, stdio: 'pipe', maxBuffer: 256 * 1024 * 1024 } - ) - ) - // The signed SBOM predicate must be the SPDX document shipped beside it. - if ( - kind === 'sbom' && - (!Array.isArray(verified) || - verified.length !== 1 || - !isDeepStrictEqual( - verified[0]?.verificationResult?.statement?.predicate, - JSON.parse(asset(`${name}.spdx.json`)) - )) + [ + name, + 'trusted-server-fastly.tar.gz', + 'provenance', + 'https://slsa.dev/provenance/v1', + ], + [ + name, + 'trusted-server-fastly.tar.gz', + 'sbom', + 'https://spdx.dev/Document/v2.3', + ], + ] + : []), + ]) + for (const [name, subject, kind, predicate] of checks) { + const verified = JSON.parse( + execFileSync( + 'gh', + [ + 'attestation', + 'verify', + path('release-assets', subject), + '--repo', + repo, + '--bundle', + path('release-assets', `${name}.${kind}.sigstore.json`), + '--source-ref', + `refs/tags/${tag}`, + '--source-digest', + source, + '--signer-digest', + source, + '--deny-self-hosted-runners', + '--predicate-type', + predicate, + '--signer-workflow', + `${repo}/.github/workflows/release-build.yml`, + '--format', + 'json', + ], + // The SBOM result embeds the whole SPDX document, which exceeds + // Node's 1 MiB default output buffer. + { timeout: 60000, stdio: 'pipe', maxBuffer: 256 * 1024 * 1024 } ) - throw Error(`Signed SBOM differs from the shipped SPDX: ${name}`) - } + ) + // The signed SBOM predicate must be the SPDX document shipped beside it. + if ( + kind === 'sbom' && + (!Array.isArray(verified) || + verified.length !== 1 || + !isDeepStrictEqual( + verified[0]?.verificationResult?.statement?.predicate, + JSON.parse(asset(`${name}.spdx.json`)) + )) + ) + throw Error(`Signed SBOM differs from the shipped SPDX: ${name}`) + } // Close API connections before synchronous gh checks can leave pooled // sockets idle. Every read is time-bounded. Mutations are single-shot and // rely on the job timeout: a client-side abort cannot cancel a create, diff --git a/scripts/release/release-cohort.json b/scripts/release/release-cohort.json new file mode 100644 index 0000000000..be477ccbb3 --- /dev/null +++ b/scripts/release/release-cohort.json @@ -0,0 +1,79 @@ +{ + "artifacts": [ + { + "assurance_profile": "provenance-sbom-repeatable", + "bin": "trusted-server-adapter-fastly", + "capabilities": ["fastly-production"], + "compatibility": { + "architecture": "wasm32", + "minimum_version": "wasip1", + "runtime": "fastly-compute" + }, + "default_features": true, + "features": [], + "kind": "wasm-core", + "name": "trusted-server-fastly.wasm", + "package": "trusted-server-adapter-fastly", + "role": "server", + "target": "wasm32-wasip1", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "aarch64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-aarch64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "aarch64-apple-darwin", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-apple-darwin", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "glibc-2.34", + "runtime": "linux-gnu" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-unknown-linux-gnu", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-unknown-linux-gnu", + "variant": "default" + } + ], + "cohort": "phase1-fastly-cli", + "eligible_branches": ["main"], + "schema_version": 1 +} diff --git a/scripts/release/validate-fastly-package.sh b/scripts/release/validate-fastly-package.sh new file mode 100644 index 0000000000..75e4897e42 --- /dev/null +++ b/scripts/release/validate-fastly-package.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Validate the generated release package and the manual fallback without deployment. +set -euxo pipefail +fastly_version=$(awk '$1 == "fastly" { print $2 }' .tool-versions) +test "$fastly_version" = 16.0.0 +route=$(mktemp -d "$RUNNER_TEMP/fastly-route.XXXXXX") +mkdir "$route/tool" "$route/config" "$route/cache" "$route/tmp" "$route/package" +archive="fastly_v${fastly_version}_linux-amd64.tar.gz" +curl --fail --silent --show-error --location --proto '=https' --retry 2 --max-time 30 \ + "https://github.com/fastly/cli/releases/download/v${fastly_version}/$archive" --output "$route/$archive" +printf '%s %s\n' b1b89112f1f1291ff275393779ffdacba8724ac64091bda0fa6000ae06308459 "$archive" > "$route/CLI.sha256" +(cd "$route" && sha256sum --check CLI.sha256) +tar -xzf "$route/$archive" -C "$route/tool" +cp release-assets/trusted-server-fastly.wasm "$route/package/trusted-server-fastly.wasm" +cp release-assets/trusted-server-fastly.tar.gz "$route/package/trusted-server-fastly.tar.gz" +cp scripts/release/fastly.toml "$route/package/release-fastly.toml" +printf 'manifest_version = 3\nname = "example-app"\n' > "$route/package/fastly.toml" +expected_wasm=$(sha256sum release-assets/trusted-server-fastly.wasm | awk '{print $1}') +expected_manifest=$(sha256sum "$route/package/fastly.toml" | awk '{print $1}') +producer="$RUNNER_TEMP/ordinary-producer-target/x86_64-unknown-linux-gnu/release/trusted-server-release" +node_program=$(command -v node) +host_network=$(readlink /proc/self/ns/net) +sudo unshare --net -- setpriv --reuid="$(id -u)" --regid="$(id -g)" \ + --clear-groups --inh-caps=-all --ambient-caps=-all --bounding-set=-all --no-new-privs \ + env -i PATH="$route/tool:/usr/bin:/bin:/usr/sbin:/sbin" XDG_CONFIG_HOME="$route/config" \ + XDG_CACHE_HOME="$route/cache" TMPDIR="$route/tmp" LC_ALL=C TZ=UTC \ + bash -euxo pipefail -s -- "$route/package" "$producer" "$expected_wasm" "$expected_manifest" "$host_network" "$node_program" <<'ROUTE' +guest_network=$(readlink /proc/self/ns/net) +test -n "$guest_network" +test "$guest_network" != "$5" +active_interfaces=$(ip -o link show up) +test -z "$active_interfaces" +cd "$1" +timeout 15 fastly version | tee cli-version.txt +grep -E '^Fastly CLI version v16\.0\.0( |$)' cli-version.txt +timeout 30 fastly compute validate --package ./trusted-server-fastly.tar.gz +timeout 30 "$2" inspect-fastly-package --package-dir . --manifest ./release-fastly.toml \ + --expected-manifest-sha256 "$(sha256sum release-fastly.toml | awk '{print $1}')" --expected-wasm-sha256 "$3" +timeout 60 fastly compute pack --wasm-binary ./trusted-server-fastly.wasm +timeout 30 fastly compute validate --package ./pkg/example-app.tar.gz +tar -tzf ./pkg/example-app.tar.gz +timeout 15 "$6" --input-type=commonjs <<'HEADERS' +const fs = require('node:fs'), zlib = require('node:zlib'); +const file = './pkg/example-app.tar.gz', stat = fs.lstatSync(file); +if (!stat.isFile() || stat.size > 768 * 1024 * 1024) throw Error('Diagnostic archive bound exceeded'); +const bytes = zlib.gunzipSync(fs.readFileSync(file), {maxOutputLength:513 * 1024 * 1024 + 16384}); +let offset = 0; +for (let count = 0; count < 8 && offset + 512 <= bytes.length; count++) { + const header = bytes.subarray(offset, offset + 512); + if (header.every(byte => byte === 0)) break; + const field = (start, end) => header.subarray(start, end).toString('utf8').split('\0')[0]; + const hidden = (start, end) => { const data = header.subarray(start, end), zero = data.indexOf(0); return zero >= 0 && data.subarray(zero + 1).some(byte => byte !== 0); }; + const rawSize = field(124, 136).trim(); + if (!/^[0-7]+$/.test(rawSize)) throw Error('Unsupported diagnostic size encoding'); + const size = parseInt(rawSize, 8); + if (!Number.isSafeInteger(size) || size > bytes.length) throw Error('Diagnostic member bound exceeded'); + console.log('Fastly raw TAR header:', JSON.stringify({path:field(0, 100), type:String.fromCharCode(header[156]), size, prefix:field(345, 500), link:field(157, 257), nonzero_padding:[[0,100],[157,257],[345,500]].some(([start,end]) => hidden(start,end))})); + offset += 512 + Math.ceil(size / 512) * 512; +} +HEADERS +timeout 30 "$2" inspect-fastly-package --package-dir ./pkg --manifest ./fastly.toml \ + --expected-manifest-sha256 "$4" --expected-wasm-sha256 "$3" +tar -xOzf ./pkg/example-app.tar.gz example-app/bin/main.wasm > packaged.wasm +cmp trusted-server-fastly.wasm packaged.wasm +tar -xOzf ./pkg/example-app.tar.gz example-app/fastly.toml > packaged.toml +cmp fastly.toml packaged.toml +printf '%s %s\n' "$3" trusted-server-fastly.wasm > input.sha256 +sha256sum --check input.sha256 +echo 'SUCCESS: pinned Fastly route packed, validated, inspected and byte-compared without credentials or network' +ROUTE +printf '%s %s\n' "$expected_wasm" release-assets/trusted-server-fastly.wasm | sha256sum --check