From 26d69ce959898859927742cd84e53f2c91e9c8ab Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 16:17:55 +0530 Subject: [PATCH 01/45] Record approved attestation phase assignment and closure checks --- ...-attestations-phases-2-4-tracking-issue.md | 70 +++ .../plans/binary-attestations-phases-2-4.md | 541 ++++++++++++++++++ .../plans/binary-attestations-slice-map.json | 460 ++++++++++++++- 3 files changed, 1070 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/binary-attestations-phases-2-4-tracking-issue.md create mode 100644 docs/superpowers/plans/binary-attestations-phases-2-4.md 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 000000000..885e90a05 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phases-2-4-tracking-issue.md @@ -0,0 +1,70 @@ +# Binary attestations: phases 2–4 + +Draft for the user to review and post. No issue has been created. + +## 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, seven 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 000000000..eb5d7efb6 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phases-2-4.md @@ -0,0 +1,541 @@ +# Binary attestations phases 2–4 + +**Status:** Assignment approved with dependency-closure corrections on 2026-10-26. Phase 2 porting may begin after the compile checks below. Capability integration and qualification remain separately gated. + +**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. + +Phase 1 baseline: `a501d1ba6bde2db82546d7e16c93facab8c613e6`, docs-only since rehearsal .9 at `149754d357901ec874eab6140e92bce3d8613085`. Do not edit #1435 further. + +| 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. Keep PRs draft, without reviewers. 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 and `fixture_for_projection` remain in Phase 2. `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. +- 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 are already landed in #1435: Fastly and CLI build scripts; core build script, Git resolver, build-info module, library registration and resolver test; Fastly main; CLI build-facts test; core constants. Preserve the current logging and documentation refinements in the last three. CLI `lib.rs` and `run.rs` also already carry the build-info re-export and labelled version output; Phase 4 ports only their remaining 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. Do not overwrite the current seven-artifact cohort or domain allowlist with frozen four-artifact versions. + +## 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 seven-artifact 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; seven-artifact index construction waits for the separate schema 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 carried documentation/evidence paths, 10 already-landed build-facts paths, 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. **Seven-artifact index amendment.** The carried v1 schema fixes four artifacts. Draft a reviewed v1.1/v2 amendment early in Phase 3 and obtain approval before schema changes or seven-artifact 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 + +- [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. +- [ ] Commit this assignment and [tracking-issue draft](binary-attestations-phases-2-4-tracking-issue.md) on Phase 2. +- [ ] Port source by module, preserving Phase 1 hunks and recording each current-main adaptation; update per-path landing metadata in the slice map. +- [ ] Adapt closed producer tables to the seven Phase 1 assets and current pins without opening qualifications or changing the index schema. +- [ ] Run explicitly selected pure tests with a nonzero-selection guard; compile platform-specific branches in CI; keep process/ignored tests CI-only. +- [ ] Obtain independent and security reviews; pass touched CI gates before pushing and rehearse only an exact green reviewed head. +- [ ] Request the separate Fastly integration design approval before implementing its missing gate/writer. Do not claim the intended Phase 2 capability is shipped by porting closed code. + +## Full path 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. Landing PRs for phases 2–4 are not created yet; their exact branch/base and intended titles are recorded above. + +| Frozen-source path | Landing phase | State | +| -------------------------------------------------------------------------------------------------------------- | ------------- | --------------------------------------------------------- | +| `docs/superpowers/plans/2026-10-13-attested-release-implementation.md` | 1 — carried | Carried in #1435 | +| `docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md` | 1 — carried | Carried in #1435 | +| `docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md` | 1 — carried | Carried in #1435 | +| `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 — carried | Carried in #1435 | +| `docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md` | 1 — carried | Carried in #1435 | +| `docs/superpowers/specs/schemas/release-index-v1.schema.json` | 1 — carried | Carried in #1435 | +| `scripts/release/js-composition.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-15-consumer-real-fixtures.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-15-js-two-builds.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-16-confined-cargo-candidate.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-16-fastly-runtime-gate.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-16-linux-cli-candidate.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-16-producer-normalized-manifests.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-19-apple-sdk-inputs.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-20-apple-cli-candidates.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-21-candidate-release-index.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-21-four-asset-candidate-join.md` | 1 — carried | Carried in #1435 | +| `scripts/release/qualification/2026-10-21-native-reproduction-schedule.md` | 1 — carried | Carried in #1435 | +| `Cargo.lock` | 2 | Pending | +| `Cargo.toml` | 2 | Pending | +| `crates/trusted-server-release/Cargo.toml` | 2 | Pending | +| `crates/trusted-server-release/src/documents.rs` | 2 | Pending | +| `crates/trusted-server-release/src/documents/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/error.rs` | 2 | Pending | +| `crates/trusted-server-release/src/git_objects.rs` | 2 | Pending | +| `crates/trusted-server-release/src/main.rs` | 2 | Pending | +| `crates/trusted-server-release/src/policy.rs` | 2 | Pending | +| `crates/trusted-server-release/src/policy/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/policy/tools.rs` | 2 | Pending | +| `crates/trusted-server-release/src/preflight.rs` | 2 | Pending | +| `crates/trusted-server-release/src/preflight/child.rs` | 2 | Pending | +| `crates/trusted-server-release/src/preflight/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/closure.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/config.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/git.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/materialize.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/tests/closure.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/tests/layout.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/tests/objects.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/tests/process.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/tests/worktree.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/tree.rs` | 2 | Pending | +| `crates/trusted-server-release/src/source/worktree.rs` | 2 | Pending | +| `crates/trusted-server-release/src/tool_tree.rs` | 2 | Pending | +| `crates/trusted-server-release/src/tool_tree/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/commands.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin` | 2 | Pending | +| `scripts/release/bootstrap.sh` | 2 | Pending | +| `scripts/release/release-cohort.json` | 2 | Pending | +| `scripts/release/release-tools.json` | 2 | Already identical; retain, no port | +| `crates/trusted-server-release/src/boundary.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/command.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/command/selector_tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/daemon.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/docker.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/docker/host_path_tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/entropy.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/host_inputs.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/macos.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/probe.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/proof.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/roots.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/seccomp.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/selector_placement_tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/sibling_tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/survivors.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/tests/container.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/tests/linux.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/tests/macos.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/tests/modes.rs` | 2 | Pending | +| `crates/trusted-server-release/src/boundary/tests/unit.rs` | 2 | Pending | +| `crates/trusted-server-release/src/lifecycle_guard.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/environment.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/environment/selector_tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/pipes.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/redact.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/supervise.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/tests/fixture.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/tests/outcomes.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/tests/redaction.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/tests/supervision.rs` | 2 | Pending | +| `crates/trusted-server-release/src/process/tests/variables.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/boundary.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/control.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs` | 2 | Pending | +| `scripts/release/qualification/SECCOMP-LICENSE` | 2 | Pending | +| `scripts/release/qualification/seccomp-default.json` | 2 | Pending | +| `crates/trusted-server-adapter-fastly/build.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | +| `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 | Pending | +| `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 | Build facts landed in #1435; preserve current refinements | +| `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 | Pending | +| `crates/trusted-server-integration-tests/tests/common/runtime.rs` | 2 | Pending | +| `crates/trusted-server-integration-tests/tests/frameworks/mod.rs` | 2 | Pending | +| `crates/trusted-server-integration-tests/tests/parity.rs` | 2 | Pending | +| `docs/superpowers/specs/2026-09-25-git-version-header-design.md` | 2 | Pending | +| `crates/trusted-server-js/Cargo.toml` | 2 | Pending | +| `crates/trusted-server-js/README.md` | 2 | Pending | +| `crates/trusted-server-js/build.rs` | 2 | Pending | +| `crates/trusted-server-js/build/bundle_set.rs` | 2 | Pending | +| `crates/trusted-server-js/build/inventory.rs` | 2 | Pending | +| `crates/trusted-server-js/build/prebuilt.rs` | 2 | Pending | +| `crates/trusted-server-js/lib/.gitignore` | 2 | Pending | +| `crates/trusted-server-js/lib/.prettierignore` | 2 | Pending | +| `crates/trusted-server-js/lib/build-all.mjs` | 2 | Pending | +| `crates/trusted-server-js/lib/build-module-options.mjs` | 2 | Pending | +| `crates/trusted-server-js/lib/eslint.config.js` | 2 | Pending | +| `crates/trusted-server-js/lib/package.json` | 2 | Pending | +| `crates/trusted-server-js/lib/release-composition.mjs` | 2 | Pending | +| `crates/trusted-server-js/lib/test/observe-npm.test.mjs` | 2 | Pending | +| `crates/trusted-server-js/lib/test/release-composition.test.mjs` | 2 | Pending | +| `crates/trusted-server-js/lib/vitest.config.ts` | 2 | Pending | +| `crates/trusted-server-js/tests/release_prebuilt.rs` | 2 | Pending | +| `crates/trusted-server-release/src/javascript.rs` | 2 | Pending | +| `crates/trusted-server-release/src/javascript/contributions.rs` | 2 | Pending | +| `crates/trusted-server-release/src/javascript/contributions/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/javascript/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/javascript/tests/adopt.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/archive.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/differential.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/index.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/source.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/cargo_vendor/writer.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/extract.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/extract/compatibility.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/extract/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/git.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/git/input.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/git/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/locked.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/manifest.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/npm_bundle.rs` | 2 | Pending | +| `crates/trusted-server-release/src/materials/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/preparation.rs` | 2 | Pending | +| `crates/trusted-server-release/src/preparation/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/preparation/tests/full.rs` | 2 | Pending | +| `scripts/release/build-js.sh` | 2 | Pending | +| `scripts/release/collect-js.mjs` | 2 | Pending | +| `scripts/release/observe-npm.mjs` | 2 | Pending | +| `scripts/release/tests/js-fixture.Dockerfile` | 2 | Pending | +| `scripts/release/tests/prepare-js-fixture.mjs` | 2 | Pending | +| `crates/trusted-server-adapter-cloudflare/build.sh` | 2 | Pending | +| `crates/trusted-server-release/src/inspect.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/elf.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/elf/loader.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/macho.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/macho/signature.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/tests/builders.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/tests/builders/macho_data.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/tests/elf.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/tests/macho.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/tests/wasm.rs` | 2 | Pending | +| `crates/trusted-server-release/src/inspect/wasm.rs` | 2 | Pending | +| `crates/trusted-server-release/src/package.rs` | 2 | Pending | +| `crates/trusted-server-release/src/package/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/ambient.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/apple.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/apple/sdk.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/apple/toolchain.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/artifact.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/artifact/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/cargo_home.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/cargo_home/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/cargo_output.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/cargo_output/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/compare.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/completed.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/execute.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/fastly_tools.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/final_paths.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/fresh.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/gnu.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/gnu/image.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/gnu/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/identity.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/identity/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/native.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/paths.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/paths/fake.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/plan.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/remap.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/runtime.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/runtime/config.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/runtime/exchange.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/runtime/export.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/runtime/export/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/runtime/logs.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/runtime/viceroy.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/sources.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/startup.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/startup/accepted.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/startup/accepted/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/startup/lifecycle.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/startup/native.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/ambient.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/bind.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/candidate.rs` | 2 | Pending | +| `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 | Pending | +| `crates/trusted-server-release/src/recipe/tests/cli_candidate.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/compare.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/complete.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/complete_darwin.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/darwin.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/fixture.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/full.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/full_macos.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/macos_candidate.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/macos_cli.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/native.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/plan.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/real_fixture.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/remap.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/runtime.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/sources.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs` | 3 | Pending | +| `crates/trusted-server-release/src/recipe/tests/tools.rs` | 2 | Pending | +| `crates/trusted-server-release/src/recipe/tools.rs` | 2 | Pending | +| `scripts/release/build.sh` | 2 | Pending | +| `scripts/release/tests/cargo-fixture.Dockerfile` | 2 | Pending | +| `scripts/release/tests/cli-builder.Dockerfile` | 2 | Pending | +| `scripts/release/tests/prepare-cargo-fixture.mjs` | 2 | Pending | +| `scripts/release/tests/prepare-cli-fixture.mjs` | 2 | Pending | +| `crates/trusted-server-release/schemas/SPDX-LICENSE` | 2 | Pending | +| `crates/trusted-server-release/schemas/spdx-2.3.schema.json` | 2 | Pending | +| `crates/trusted-server-release/src/composition.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/capture.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/external.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/external/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/graph.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/graph/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/javascript.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/javascript/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/native.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/native/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/origin.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/origin/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/platform.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/platform/tests.rs` | 2 | Pending | +| `crates/trusted-server-release/src/composition/tests.rs` | 2 | Pending | +| `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 | Pending | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-host-metadata.json` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-messages.jsonl` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-target-filtered-metadata.json` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-tree.txt` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-messages.jsonl` | 2 | Pending | +| `crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-metadata.json` | 2 | Pending | +| `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 | Pending | +| `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 | Pending | +| `CHANGELOG.md` | 2 | Pending | +| `crates/trusted-server-adapter-spin/src/app.rs` | 2 | Pending | +| `crates/trusted-server-adapter-spin/src/middleware.rs` | 2 | Pending | +| `crates/trusted-server-adapter-spin/tests/routes.rs` | 2 | Pending | +| `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 | Pending | +| `docs/guide/error-reference.md` | 2 | Pending | +| `docs/guide/getting-started.md` | 2 | Pending | +| `docs/guide/release-verification.md` | 4 | Pending | +| `scripts/docs-proposal/hunks.mjs` | 2 | Pending | +| `scripts/docs-proposal/hunks.test.mjs` | 2 | Pending | +| `scripts/release/qualification/README.md` | 2 | Pending | +| `scripts/release/tests/fixtures/README.md` | 2 | Pending | diff --git a/docs/superpowers/plans/binary-attestations-slice-map.json b/docs/superpowers/plans/binary-attestations-slice-map.json index 84f58a1e8..b5e3dc0b8 100644 --- a/docs/superpowers/plans/binary-attestations-slice-map.json +++ b/docs/superpowers/plans/binary-attestations-slice-map.json @@ -6,7 +6,8 @@ "File ownership is a planning map, not safe cherry-pick boundaries: main.rs, Cargo manifests/lock, shared fixtures and workflow YAML require staged hunks.", "Phase 1 linked evidence closure includes source-modified legacy spec, migration addendum, two integration plans, 13 dated evidence reports and JS composition narrative; 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); release.yml inline publisher is replaced by slice 2.8. Slice 2.3 immutable build facts are partly adopted now; CLI source-resource cleanup remains deferred." + "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); release.yml inline publisher 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": { @@ -677,5 +678,462 @@ ".github/workflows/release.yml inline publisher": { "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": null + }, + "3": { + "branch": "feature/binary-attestations-phase3", + "base": "feature/binary-attestations-phase2", + "pull_request": null + }, + "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 } } From 4ee86da6ca6eebe4afed185029272ff9150a7b74 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 16:23:13 +0530 Subject: [PATCH 02/45] Require runtime source resources in release CLI builds Port the remaining slice 2.3 resource-discovery change from #1262 at 122b78664b1235c717e9208fd200f9a9b0c335df. Preserve the build facts already carried in #1435. Verify the three pure filesystem tests in development and release-build configurations with exact-name selection guards. CLI clippy, Rust formatting, domain lint and independent review pass. --- .../trusted-server-cli/src/prebid_bundle.rs | 85 +++++++++++++++++-- .../plans/binary-attestations-phases-2-4.md | 10 ++- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs index 609c95053..1ad5d605b 100644 --- a/crates/trusted-server-cli/src/prebid_bundle.rs +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -620,6 +620,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) { @@ -627,11 +639,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}", @@ -835,6 +845,71 @@ fn write_atomic(path: &Path, contents: &str) -> CliResult<()> { mod tests { use super::*; + #[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/docs/superpowers/plans/binary-attestations-phases-2-4.md b/docs/superpowers/plans/binary-attestations-phases-2-4.md index eb5d7efb6..1f71b33cb 100644 --- a/docs/superpowers/plans/binary-attestations-phases-2-4.md +++ b/docs/superpowers/plans/binary-attestations-phases-2-4.md @@ -92,13 +92,19 @@ The local unpublished GNU masking commit `b91c7450a4f617d5ec879f612b295a532c7d7e - [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. -- [ ] Commit this assignment and [tracking-issue draft](binary-attestations-phases-2-4-tracking-issue.md) on Phase 2. +- [x] Commit this assignment and [tracking-issue draft](binary-attestations-phases-2-4-tracking-issue.md) on Phase 2 (`26d69ce95`). - [ ] Port source by module, preserving Phase 1 hunks and recording each current-main adaptation; update per-path landing metadata in the slice map. - [ ] Adapt closed producer tables to the seven Phase 1 assets and current pins without opening qualifications or changing the index schema. - [ ] Run explicitly selected pure tests with a nonzero-selection guard; compile platform-specific branches in CI; keep process/ignored tests CI-only. - [ ] Obtain independent and security reviews; pass touched CI gates before pushing and rehearse only an exact green reviewed head. - [ ] Request the separate Fastly integration design approval before implementing its missing gate/writer. Do not claim the intended Phase 2 capability is shipped by porting closed code. +### 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 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. Landing PRs for phases 2–4 are not created yet; their exact branch/base and intended titles are recorded above. @@ -218,7 +224,7 @@ The landing phase below is exclusive file ownership. Shared files receive later | `crates/trusted-server-adapter-fastly/build.rs` | 1 — carried | Build facts landed in #1435; preserve current refinements | | `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 | Pending | +| `crates/trusted-server-cli/src/prebid_bundle.rs` | 2 | Initial source-identical port | | `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 | From 15a7b409daf342f08857d38bfd3a6b2a39caf98c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 16:53:03 +0530 Subject: [PATCH 03/45] Propose ordinary Fastly release equality integration Record Gate 1 for user approval: real A/B comparison and rebuild-check publication preserve Phase 1 identities while qualification remains closed. The narrower ordinary-release assurance claim requires explicit approval before implementation. --- ...-attestations-phase2-fastly-integration.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md 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 000000000..7bb3a5c50 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md @@ -0,0 +1,98 @@ +# Phase 2 Fastly release integration — Gate 1 + +**Status:** Proposed for user approval. This document authorizes no implementation. Continue the approved frozen-source port while this gate is pending. + +**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 eventual integration must first be implemented and rehearsed in the fork; activating a new gate for IAB releases remains a separate maintainer decision. The port can land independently with current release behavior intact. + +## 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 existing build matrix remains seven binaries. 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: **29 existing assets become 30**. `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, release membership checks and post-publish byte comparison. Expand exact asset membership/digest expectations to 30. 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 30-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 seven 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 30 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. Keep it draft without reviewers. Do not claim the full controlled-recipe profile or Phase 2 readiness if only the ordinary bridge is approved/proven. + +## Approval checklist + +- [ ] User accepts the ordinary-release adapter's narrower equality claim for this Phase 2 slice, or selects the controlled-recipe prerequisite alternative. +- [ ] User approves the concrete informational writer/measurement/transfer contract and additive Fastly provenance subject. +- [ ] User approves implementation after a concrete plan is written and independently reviewed within this design; qualification types/selectors and new triggers remain closed. +- [ ] Before activation for IAB tags, maintainers separately accept the rehearsed gate and required runner/resource costs. This document changes no setting or release behavior. From 8874e4f358432ad46d2fae9ef38eca98d91514fa Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 17:00:45 +0530 Subject: [PATCH 04/45] Record approved Fastly gate activation and compatibility conditions --- ...binary-attestations-phase1-compatibility.md | 8 ++++++++ ...y-attestations-phase2-fastly-integration.md | 18 ++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index 7cd8d2e67..9de3d1a1c 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -303,3 +303,11 @@ Post-publish downloads use fresh temporary directories and bounded transient rea ### Follow-ups - Axum invalid-port diagnostics: `PORT=0` is correctly refused, but the forwarded EdgeZero warning names `EDGEZERO__ADAPTER__PORT="0"` and says “falling back.” Reword the error to name the actual supplied variable and state that startup is refused. This changes the binary and requires a fresh rehearsal; it is not part of this docs-only update. + +## 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. + +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. Merging Phase 2 activates the gate on the next real tag, adding 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. diff --git a/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md index 7bb3a5c50..357d79e08 100644 --- a/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md +++ b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md @@ -1,6 +1,6 @@ # Phase 2 Fastly release integration — Gate 1 -**Status:** Proposed for user approval. This document authorizes no implementation. Continue the approved frozen-source port while this gate is pending. +**Status:** Approved on 2026-10-26 for the ordinary-release adapter, writer, transfer binding and additive provenance subject, subject to the conditions below. Write and independently review the concrete implementation plan before implementation. Continue the frozen-source port meanwhile. **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. @@ -8,7 +8,7 @@ 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 eventual integration must first be implemented and rehearsed in the fork; activating a new gate for IAB releases remains a separate maintainer decision. The port can land independently with current release behavior intact. +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:** the next real tag enforces the Fastly gate, without a separate switch. Each release 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 @@ -92,7 +92,13 @@ Preserve single-shot listing/refusal, draft creation, uploads and publication by ## Approval checklist -- [ ] User accepts the ordinary-release adapter's narrower equality claim for this Phase 2 slice, or selects the controlled-recipe prerequisite alternative. -- [ ] User approves the concrete informational writer/measurement/transfer contract and additive Fastly provenance subject. -- [ ] User approves implementation after a concrete plan is written and independently reviewed within this design; qualification types/selectors and new triggers remain closed. -- [ ] Before activation for IAB tags, maintainers separately accept the rehearsed gate and required runner/resource costs. This document changes no setting or release behavior. +- [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. +- [ ] Rehearse the exact reviewed head and report time/cost and unchanged Phase 1 verification before merge. Merging Phase 2 activates the gate on the next real 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. From 1534d462d052debae8b5d886268701077bf879a5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 17:01:39 +0530 Subject: [PATCH 05/45] Port strict release documents and process supervision Port the closed producer primitives from #1262 at 122b78664b1235c717e9208fd200f9a9b0c335df. Stage the manifest, error variants and fail-closed entrypoint; keep selection, signing and publication unavailable. Run 49 audited pure tests locally and wire full process tests and strict clippy into existing Linux CI jobs. Carry schemas unchanged with approved exact reference-host entries and rejection coverage. Document required shared dependency upgrades. --- .github/workflows/format.yml | 9 +- .github/workflows/test.yml | 9 +- Cargo.lock | 258 ++++- Cargo.toml | 1 + .../src/commands/dev/lint/domains.rs | 25 + crates/trusted-server-release/Cargo.toml | 23 + .../schemas/SPDX-LICENSE | 92 ++ .../schemas/spdx-2.3.schema.json | 740 +++++++++++++++ .../trusted-server-release/src/documents.rs | 765 +++++++++++++++ .../src/documents/tests.rs | 883 ++++++++++++++++++ crates/trusted-server-release/src/error.rs | 20 + .../trusted-server-release/src/git_objects.rs | 394 ++++++++ crates/trusted-server-release/src/main.rs | 16 + crates/trusted-server-release/src/process.rs | 651 +++++++++++++ .../src/process/environment.rs | 227 +++++ .../src/process/environment/selector_tests.rs | 74 ++ .../src/process/pipes.rs | 159 ++++ .../src/process/redact.rs | 63 ++ .../src/process/supervise.rs | 233 +++++ .../src/process/tests.rs | 56 ++ .../src/process/tests/fixture.rs | 509 ++++++++++ .../src/process/tests/outcomes.rs | 237 +++++ .../src/process/tests/redaction.rs | 171 ++++ .../src/process/tests/supervision.rs | 298 ++++++ .../src/process/tests/variables.rs | 231 +++++ .../plans/binary-attestations-phases-2-4.md | 14 + .../release/qualification/results.schema.json | 554 +++++++++++ 27 files changed, 6692 insertions(+), 20 deletions(-) create mode 100644 crates/trusted-server-release/Cargo.toml create mode 100644 crates/trusted-server-release/schemas/SPDX-LICENSE create mode 100644 crates/trusted-server-release/schemas/spdx-2.3.schema.json create mode 100644 crates/trusted-server-release/src/documents.rs create mode 100644 crates/trusted-server-release/src/documents/tests.rs create mode 100644 crates/trusted-server-release/src/error.rs create mode 100644 crates/trusted-server-release/src/git_objects.rs create mode 100644 crates/trusted-server-release/src/main.rs create mode 100644 crates/trusted-server-release/src/process.rs create mode 100644 crates/trusted-server-release/src/process/environment.rs create mode 100644 crates/trusted-server-release/src/process/environment/selector_tests.rs create mode 100644 crates/trusted-server-release/src/process/pipes.rs create mode 100644 crates/trusted-server-release/src/process/redact.rs create mode 100644 crates/trusted-server-release/src/process/supervise.rs create mode 100644 crates/trusted-server-release/src/process/tests.rs create mode 100644 crates/trusted-server-release/src/process/tests/fixture.rs create mode 100644 crates/trusted-server-release/src/process/tests/outcomes.rs create mode 100644 crates/trusted-server-release/src/process/tests/redaction.rs create mode 100644 crates/trusted-server-release/src/process/tests/supervision.rs create mode 100644 crates/trusted-server-release/src/process/tests/variables.rs create mode 100644 scripts/release/qualification/results.schema.json diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 4c9f085b3..de6f87e2d 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -91,6 +91,9 @@ 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 + # 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 @@ -113,7 +116,7 @@ jobs: - name: Retrieve Node.js version id: node-version working-directory: . - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js @@ -144,7 +147,7 @@ jobs: - name: Retrieve Node.js version id: node-version working-directory: . - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js @@ -180,7 +183,7 @@ jobs: - name: Retrieve Node.js version id: node-version - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b27173dd6..d3eac8e49 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,12 +20,12 @@ jobs: id: viceroy-version # `.tool-versions` is the single source of truth so this workflow and # `.github/actions/setup-integration-test-env/action.yml` can't drift. - run: echo "viceroy-version=$(grep '^viceroy ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "viceroy-version=$(grep '^viceroy ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Retrieve Node.js version id: node-version - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust toolchain @@ -104,6 +104,9 @@ jobs: - name: Run host-target CLI tests run: ./scripts/test-cli.sh + - name: Run release producer primitive and process tests + run: cargo test --package trusted-server-release --target x86_64-unknown-linux-gnu --locked + # 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 @@ -268,7 +271,7 @@ jobs: - name: Retrieve Node.js version id: node-version working-directory: . - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js diff --git a/Cargo.lock b/Cargo.lock index 7ebc0b08e..9ef504bb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,20 @@ dependencies = [ "inout", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -387,6 +401,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -515,6 +544,12 @@ dependencies = [ "serde_with", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli" version = "8.0.4" @@ -568,6 +603,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -1760,6 +1801,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1872,6 +1922,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -2015,6 +2076,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2036,6 +2108,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2594,7 +2676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595" dependencies = [ "bstr", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -3428,7 +3510,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "strum_macros", + "strum_macros 0.27.2", "syn 2.0.118", "thiserror 2.0.18", "walkdir", @@ -3832,6 +3914,59 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonschema" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum 0.28.0", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "kstring" version = "2.0.2" @@ -4047,6 +4182,12 @@ dependencies = [ "libc", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -4148,9 +4289,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -4172,6 +4313,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -4318,6 +4465,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -4790,7 +4943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.10.5", + "itertools 0.13.0", "log", "multimap", "once_cell", @@ -4810,7 +4963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.118", @@ -4823,7 +4976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.118", @@ -5079,6 +5232,23 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "referencing" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.4" @@ -5093,9 +5263,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6004,7 +6174,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -6019,6 +6198,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6788,6 +6979,23 @@ dependencies = [ "prost-build", ] +[[package]] +name = "trusted-server-release" +version = "0.1.0" +dependencies = [ + "derive_more", + "error-stack", + "flate2", + "hex", + "jsonschema", + "libc", + "serde", + "serde_json", + "sha1-checked", + "sha2 0.11.1", + "tempfile", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6845,6 +7053,12 @@ version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6981,6 +7195,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "validator" version = "0.21.0" @@ -7022,6 +7246,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -7635,7 +7865,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "serde_urlencoded", - "strum", + "strum 0.27.2", "tokio", "url", "wasm-bindgen", @@ -7655,7 +7885,7 @@ dependencies = [ "async-trait", "proc-macro2", "quote", - "strum", + "strum 0.27.2", "syn 2.0.118", "wasm-bindgen", "wasm-bindgen-macro-support", @@ -7842,6 +8072,6 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 72fecdf6b..b8525f032 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-cli/src/commands/dev/lint/domains.rs b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs index 5b6df9615..2881a8291 100644 --- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -106,6 +106,10 @@ pub const REFERENCE_HOSTS: &[&str] = &[ // Web / W3C standards "www.w3.org", "schema.org", + // Canonical JSON Schema draft identifiers in the carried release schemas. + "json-schema.org", + // Canonical SPDX schema identity and license identifiers in the carried schema. + "spdx.org", // Versioning / changelogs "semver.org", "keepachangelog.com", @@ -365,6 +369,27 @@ mod allow_check_tests { } } + #[test] + fn release_schema_reference_hosts_are_exact() { + for host in ["json-schema.org", "spdx.org"] { + assert!( + is_allowed(host, ¬hing_suppressed()), + "should allow the approved canonical schema reference host" + ); + } + for host in [ + "unapproved.json-schema.org", + "json-schema.org.invalid-host.com", + "unapproved.spdx.org", + "spdx.org.invalid-host.com", + ] { + assert!( + !is_allowed(host, ¬hing_suppressed()), + "should reject schema host subdomains and lookalikes" + ); + } + } + #[test] fn suppression_set_allows() { let mut suppressed = HashSet::new(); diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml new file mode 100644 index 000000000..48bafce52 --- /dev/null +++ b/crates/trusted-server-release/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "trusted-server-release" +edition.workspace = true +license.workspace = true +publish.workspace = true +version.workspace = true +rust-version = "1.98" + +[dependencies] +derive_more.workspace = true +error-stack.workspace = true +flate2.workspace = true +hex.workspace = true +libc.workspace = true +jsonschema = { version = "=0.58.6", default-features = false } +serde.workspace = true +serde_json.workspace = true +sha1-checked = { version = "=0.10.0", default-features = false } +sha2.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/trusted-server-release/schemas/SPDX-LICENSE b/crates/trusted-server-release/schemas/SPDX-LICENSE new file mode 100644 index 000000000..44a22d370 --- /dev/null +++ b/crates/trusted-server-release/schemas/SPDX-LICENSE @@ -0,0 +1,92 @@ +Creative Commons Attribution 3.0 Unported + +CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM ITS USE. +License + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND CONDITIONS. + +1. Definitions + +a. "Adaptation" means a work based upon the Work, or upon the Work and other pre-existing works, such as a translation, adaptation, derivative work, arrangement of music or other alterations of a literary or artistic work, or phonogram or performance and includes cinematographic adaptations or any other form in which the Work may be recast, transformed, or adapted including in any form recognizably derived from the original, except that a work that constitutes a Collection will not be considered an Adaptation for the purpose of this License. For the avoidance of doubt, where the Work is a musical work, performance or phonogram, the synchronization of the Work in timed-relation with a moving image ("synching") will be considered an Adaptation for the purpose of this License. + +b. "Collection" means a collection of literary or artistic works, such as encyclopedias and anthologies, or performances, phonograms or broadcasts, or other works or subject matter other than works listed in Section 1(f) below, which, by reason of the selection and arrangement of their contents, constitute intellectual creations, in which the Work is included in its entirety in unmodified form along with one or more other contributions, each constituting separate and independent works in themselves, which together are assembled into a collective whole. A work that constitutes a Collection will not be considered an Adaptation (as defined above) for the purposes of this License. + +c. "Distribute" means to make available to the public the original and copies of the Work or Adaptation, as appropriate, through sale or other transfer of ownership. + +d. "Licensor" means the individual, individuals, entity or entities that offer(s) the Work under the terms of this License. + +e. "Original Author" means, in the case of a literary or artistic work, the individual, individuals, entity or entities who created the Work or if no individual or entity can be identified, the publisher; and in addition (i) in the case of a performance the actors, singers, musicians, dancers, and other persons who act, sing, deliver, declaim, play in, interpret or otherwise perform literary or artistic works or expressions of folklore; (ii) in the case of a phonogram the producer being the person or legal entity who first fixes the sounds of a performance or other sounds; and, (iii) in the case of broadcasts, the organization that transmits the broadcast. + +f. "Work" means the literary and/or artistic work offered under the terms of this License including without limitation any production in the literary, scientific and artistic domain, whatever may be the mode or form of its expression including digital form, such as a book, pamphlet and other writing; a lecture, address, sermon or other work of the same nature; a dramatic or dramatico-musical work; a choreographic work or entertainment in dumb show; a musical composition with or without words; a cinematographic work to which are assimilated works expressed by a process analogous to cinematography; a work of drawing, painting, architecture, sculpture, engraving or lithography; a photographic work to which are assimilated works expressed by a process analogous to photography; a work of applied art; an illustration, map, plan, sketch or three-dimensional work relative to geography, topography, architecture or science; a performance; a broadcast; a phonogram; a compilation of data to the extent it is protected as a copyrightable work; or a work performed by a variety or circus performer to the extent it is not otherwise considered a literary or artistic work. + +g. "You" means an individual or entity exercising rights under this License who has not previously violated the terms of this License with respect to the Work, or who has received express permission from the Licensor to exercise rights under this License despite a previous violation. + +h. "Publicly Perform" means to perform public recitations of the Work and to communicate to the public those public recitations, by any means or process, including by wire or wireless means or public digital performances; to make available to the public Works in such a way that members of the public may access these Works from a place and at a place individually chosen by them; to perform the Work to the public by any means or process and the communication to the public of the performances of the Work, including by public digital performance; to broadcast and rebroadcast the Work by any means including signs, sounds or images. + +i. "Reproduce" means to make copies of the Work by any means including without limitation by sound or visual recordings and the right of fixation and reproducing fixations of the Work, including storage of a protected performance or phonogram in digital form or other electronic medium. + +2. Fair Dealing Rights. Nothing in this License is intended to reduce, limit, or restrict any uses free from copyright or rights arising from limitations or exceptions that are provided for in connection with the copyright protection under copyright law or other applicable laws. + +3. License Grant. Subject to the terms and conditions of this License, Licensor hereby grants You a worldwide, royalty-free, non-exclusive, perpetual (for the duration of the applicable copyright) license to exercise the rights in the Work as stated below: + +a. to Reproduce the Work, to incorporate the Work into one or more Collections, and to Reproduce the Work as incorporated in the Collections; + +b. to create and Reproduce Adaptations provided that any such Adaptation, including any translation in any medium, takes reasonable steps to clearly label, demarcate or otherwise identify that changes were made to the original Work. For example, a translation could be marked "The original work was translated from English to Spanish," or a modification could indicate "The original work has been modified."; + +c. to Distribute and Publicly Perform the Work including as incorporated in Collections; and, + +d. to Distribute and Publicly Perform Adaptations. + +e. For the avoidance of doubt: + +i. Non-waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme cannot be waived, the Licensor reserves the exclusive right to collect such royalties for any exercise by You of the rights granted under this License; + +ii. Waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme can be waived, the Licensor waives the exclusive right to collect such royalties for any exercise by You of the rights granted under this License; and, + +iii. Voluntary License Schemes. The Licensor waives the right to collect royalties, whether individually or, in the event that the Licensor is a member of a collecting society that administers voluntary licensing schemes, via that society, from any exercise by You of the rights granted under this License. + +The above rights may be exercised in all media and formats whether now known or hereafter devised. The above rights include the right to make such modifications as are technically necessary to exercise the rights in other media and formats. Subject to Section 8(f), all rights not expressly granted by Licensor are hereby reserved. + +4. Restrictions. The license granted in Section 3 above is expressly made subject to and limited by the following restrictions: + +a. You may Distribute or Publicly Perform the Work only under the terms of this License. You must include a copy of, or the Uniform Resource Identifier (URI) for, this License with every copy of the Work You Distribute or Publicly Perform. You may not offer or impose any terms on the Work that restrict the terms of this License or the ability of the recipient of the Work to exercise the rights granted to that recipient under the terms of the License. You may not sublicense the Work. You must keep intact all notices that refer to this License and to the disclaimer of warranties with every copy of the Work You Distribute or Publicly Perform. When You Distribute or Publicly Perform the Work, You may not impose any effective technological measures on the Work that restrict the ability of a recipient of the Work from You to exercise the rights granted to that recipient under the terms of the License. This Section 4(a) applies to the Work as incorporated in a Collection, but this does not require the Collection apart from the Work itself to be made subject to the terms of this License. If You create a Collection, upon notice from any Licensor You must, to the extent practicable, remove from the Collection any credit as required by Section 4(b), as requested. If You create an Adaptation, upon notice from any Licensor You must, to the extent practicable, remove from the Adaptation any credit as required by Section 4(b), as requested. + +b. If You Distribute, or Publicly Perform the Work or any Adaptations or Collections, You must, unless a request has been made pursuant to Section 4(a), keep intact all copyright notices for the Work and provide, reasonable to the medium or means You are utilizing: (i) the name of the Original Author (or pseudonym, if applicable) if supplied, and/or if the Original Author and/or Licensor designate another party or parties (e.g., a sponsor institute, publishing entity, journal) for attribution ("Attribution Parties") in Licensor's copyright notice, terms of service or by other reasonable means, the name of such party or parties; (ii) the title of the Work if supplied; (iii) to the extent reasonably practicable, the URI, if any, that Licensor specifies to be associated with the Work, unless such URI does not refer to the copyright notice or licensing information for the Work; and (iv) , consistent with Section 3(b), in the case of an Adaptation, a credit identifying the use of the Work in the Adaptation (e.g., "French translation of the Work by Original Author," or "Screenplay based on original Work by Original Author"). The credit required by this Section 4 (b) may be implemented in any reasonable manner; provided, however, that in the case of a Adaptation or Collection, at a minimum such credit will appear, if a credit for all contributing authors of the Adaptation or Collection appears, then as part of these credits and in a manner at least as prominent as the credits for the other contributing authors. For the avoidance of doubt, You may only use the credit required by this Section for the purpose of attribution in the manner set out above and, by exercising Your rights under this License, You may not implicitly or explicitly assert or imply any connection with, sponsorship or endorsement by the Original Author, Licensor and/or Attribution Parties, as appropriate, of You or Your use of the Work, without the separate, express prior written permission of the Original Author, Licensor and/or Attribution Parties. + +c. Except as otherwise agreed in writing by the Licensor or as may be otherwise permitted by applicable law, if You Reproduce, Distribute or Publicly Perform the Work either by itself or as part of any Adaptations or Collections, You must not distort, mutilate, modify or take other derogatory action in relation to the Work which would be prejudicial to the Original Author's honor or reputation. Licensor agrees that in those jurisdictions (e.g. Japan), in which any exercise of the right granted in Section 3(b) of this License (the right to make Adaptations) would be deemed to be a distortion, mutilation, modification or other derogatory action prejudicial to the Original Author's honor and reputation, the Licensor will waive or not assert, as appropriate, this Section, to the fullest extent permitted by the applicable national law, to enable You to reasonably exercise Your right under Section 3(b) of this License (right to make Adaptations) but not otherwise. + +5. Representations, Warranties and Disclaimer + +UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. + +6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. Termination + +a. This License and the rights granted hereunder will terminate automatically upon any breach by You of the terms of this License. Individuals or entities who have received Adaptations or Collections from You under this License, however, will not have their licenses terminated provided such individuals or entities remain in full compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any termination of this License. + +b. Subject to the above terms and conditions, the license granted here is perpetual (for the duration of the applicable copyright in the Work). Notwithstanding the above, Licensor reserves the right to release the Work under different license terms or to stop distributing the Work at any time; provided, however that any such election will not serve to withdraw this License (or any other license that has been, or is required to be, granted under the terms of this License), and this License will continue in full force and effect unless terminated as stated above. + +8. Miscellaneous + +a. Each time You Distribute or Publicly Perform the Work or a Collection, the Licensor offers to the recipient a license to the Work on the same terms and conditions as the license granted to You under this License. + +b. Each time You Distribute or Publicly Perform an Adaptation, Licensor offers to the recipient a license to the original Work on the same terms and conditions as the license granted to You under this License. + +c. If any provision of this License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this License, and without further action by the parties to this agreement, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable. + +d. No term or provision of this License shall be deemed waived and no breach consented to unless such waiver or consent shall be in writing and signed by the party to be charged with such waiver or consent. This License constitutes the entire agreement between the parties with respect to the Work licensed here. There are no understandings, agreements or representations with respect to the Work not specified here. Licensor shall not be bound by any additional provisions that may appear in any communication from You. + +e. This License may not be modified without the mutual written agreement of the Licensor and You. + +f. The rights granted under, and the subject matter referenced, in this License were drafted utilizing the terminology of the Berne Convention for the Protection of Literary and Artistic Works (as amended on September 28, 1979), the Rome Convention of 1961, the WIPO Copyright Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 and the Universal Copyright Convention (as revised on July 24, 1971). These rights and subject matter take effect in the relevant jurisdiction in which the License terms are sought to be enforced according to the corresponding provisions of the implementation of those treaty provisions in the applicable national law. If the standard suite of rights granted under applicable copyright law includes additional rights not granted under this License, such additional rights are deemed to be included in the License; this License is not intended to restrict the license of any rights under applicable law. + +Creative Commons Notice + +Creative Commons is not a party to this License, and makes no warranty whatsoever in connection with the Work. Creative Commons will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, if Creative Commons has expressly identified itself as the Licensor hereunder, it shall have all rights and obligations of Licensor. + +Except for the limited purpose of indicating to the public that the Work is licensed under the CCPL, Creative Commons does not authorize the use by either party of the trademark "Creative Commons" or any related trademark or logo of Creative Commons without the prior written consent of Creative Commons. Any permitted use will be in compliance with Creative Commons' then-current trademark usage guidelines, as may be published on its website or otherwise made available upon request from time to time. For the avoidance of doubt, this trademark restriction does not form part of this License. + +Creative Commons may be contacted at http://creativecommons.org/. \ No newline at end of file diff --git a/crates/trusted-server-release/schemas/spdx-2.3.schema.json b/crates/trusted-server-release/schemas/spdx-2.3.schema.json new file mode 100644 index 000000000..ee61e6686 --- /dev/null +++ b/crates/trusted-server-release/schemas/spdx-2.3.schema.json @@ -0,0 +1,740 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "$id" : "http://spdx.org/rdf/terms/2.3", + "title" : "SPDX 2.3", + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "comment" : { + "type" : "string" + }, + "creationInfo" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "created" : { + "description" : "Identify when the SPDX document was originally created. The date is to be specified according to combined date and time in UTC format as specified in ISO 8601 standard.", + "type" : "string" + }, + "creators" : { + "description" : "Identify who (or what, in the case of a tool) created the SPDX document. If the SPDX document was created by an individual, indicate the person's name. If the SPDX document was created on behalf of a company or organization, indicate the entity name. If the SPDX document was created using a software tool, indicate the name and version for that tool. If multiple participants or tools were involved, use multiple instances of this field. Person name or organization name may be designated as “anonymous” if appropriate.", + "minItems" : 1, + "type" : "array", + "items" : { + "description" : "Identify who (or what, in the case of a tool) created the SPDX document. If the SPDX document was created by an individual, indicate the person's name. If the SPDX document was created on behalf of a company or organization, indicate the entity name. If the SPDX document was created using a software tool, indicate the name and version for that tool. If multiple participants or tools were involved, use multiple instances of this field. Person name or organization name may be designated as “anonymous” if appropriate.", + "type" : "string" + } + }, + "licenseListVersion" : { + "description" : "An optional field for creators of the SPDX file to provide the version of the SPDX License List used when the SPDX file was created.", + "type" : "string" + } + }, + "required" : [ "created", "creators" ], + "additionalProperties" : false, + "description" : "One instance is required for each SPDX file produced. It provides the necessary information for forward and backward compatibility for processing tools." + }, + "dataLicense" : { + "description" : "License expression for dataLicense. See SPDX Annex D for the license expression syntax. Compliance with the SPDX specification includes populating the SPDX fields therein with data related to such fields (\"SPDX-Metadata\"). The SPDX specification contains numerous fields where an SPDX document creator may provide relevant explanatory text in SPDX-Metadata. Without opining on the lawfulness of \"database rights\" (in jurisdictions where applicable), such explanatory text is copyrightable subject matter in most Berne Convention countries. By using the SPDX specification, or any portion hereof, you hereby agree that any copyright rights (as determined by your jurisdiction) in any SPDX-Metadata, including without limitation explanatory text, shall be subject to the terms of the Creative Commons CC0 1.0 Universal license. For SPDX-Metadata not containing any copyright rights, you hereby agree and acknowledge that the SPDX-Metadata is provided to you \"as-is\" and without any representations or warranties of any kind concerning the SPDX-Metadata, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non-infringement, or the absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.", + "type" : "string" + }, + "externalDocumentRefs" : { + "description" : "Identify any external SPDX documents referenced within this SPDX document.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "checksum" : { + "type" : "object", + "properties" : { + "algorithm" : { + "description" : "Identifies the algorithm used to produce the subject Checksum. Currently, SHA-1 is the only supported algorithm. It is anticipated that other algorithms will be supported at a later time.", + "type" : "string", + "enum" : [ "SHA1", "BLAKE3", "SHA3-384", "SHA256", "SHA384", "BLAKE2b-512", "BLAKE2b-256", "SHA3-512", "MD2", "ADLER32", "MD4", "SHA3-256", "BLAKE2b-384", "SHA512", "MD6", "MD5", "SHA224" ] + }, + "checksumValue" : { + "description" : "The checksumValue property provides a lower case hexidecimal encoded digest value produced using a specific algorithm.", + "type" : "string" + } + }, + "required" : [ "algorithm", "checksumValue" ], + "additionalProperties" : false, + "description" : "A Checksum is value that allows the contents of a file to be authenticated. Even small changes to the content of the file will change its checksum. This class allows the results of a variety of checksum and cryptographic message digest algorithms to be represented." + }, + "externalDocumentId" : { + "description" : "externalDocumentId is a string containing letters, numbers, ., - and/or + which uniquely identifies an external document within this document.", + "type" : "string" + }, + "spdxDocument" : { + "description" : "SPDX ID for SpdxDocument. A property containing an SPDX document.", + "type" : "string" + } + }, + "required" : [ "checksum", "externalDocumentId", "spdxDocument" ], + "additionalProperties" : false, + "description" : "Information about an external SPDX document reference including the checksum. This allows for verification of the external references." + } + }, + "hasExtractedLicensingInfos" : { + "description" : "Indicates that a particular ExtractedLicensingInfo was defined in the subject SpdxDocument.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "crossRefs" : { + "description" : "Cross Reference Detail for a license SeeAlso URL", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "isLive" : { + "description" : "Indicate a URL is still a live accessible location on the public internet", + "type" : "boolean" + }, + "isValid" : { + "description" : "True if the URL is a valid well formed URL", + "type" : "boolean" + }, + "isWayBackLink" : { + "description" : "True if the License SeeAlso URL points to a Wayback archive", + "type" : "boolean" + }, + "match" : { + "description" : "Status of a License List SeeAlso URL reference if it refers to a website that matches the license text.", + "type" : "string" + }, + "order" : { + "description" : "The ordinal order of this element within a list", + "type" : "integer" + }, + "timestamp" : { + "description" : "Timestamp", + "type" : "string" + }, + "url" : { + "description" : "URL Reference", + "type" : "string" + } + }, + "required" : [ "url" ], + "additionalProperties" : false, + "description" : "Cross reference details for the a URL reference" + } + }, + "extractedText" : { + "description" : "Provide a copy of the actual text of the license reference extracted from the package, file or snippet that is associated with the License Identifier to aid in future analysis.", + "type" : "string" + }, + "licenseId" : { + "description" : "A human readable short form license identifier for a license. The license ID is either on the standard license list or the form \"LicenseRef-[idString]\" where [idString] is a unique string containing letters, numbers, \".\" or \"-\". When used within a license expression, the license ID can optionally include a reference to an external document in the form \"DocumentRef-[docrefIdString]:LicenseRef-[idString]\" where docRefIdString is an ID for an external document reference.", + "type" : "string" + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "seeAlsos" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "required" : [ "extractedText", "licenseId" ], + "additionalProperties" : false, + "description" : "An ExtractedLicensingInfo represents a license or licensing notice that was found in a package, file or snippet. Any license text that is recognized as a license may be represented as a License rather than an ExtractedLicensingInfo." + } + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "revieweds" : { + "description" : "Reviewed", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "reviewDate" : { + "description" : "The date and time at which the SpdxDocument was reviewed. This value must be in UTC and have 'Z' as its timezone indicator.", + "type" : "string" + }, + "reviewer" : { + "description" : "The name and, optionally, contact information of the person who performed the review. Values of this property must conform to the agent and tool syntax. The reviewer property is deprecated in favor of Annotation with an annotationType review.", + "type" : "string" + } + }, + "required" : [ "reviewDate" ], + "additionalProperties" : false, + "description" : "This class has been deprecated in favor of an Annotation with an Annotation type of review." + } + }, + "spdxVersion" : { + "description" : "Provide a reference number that can be used to understand how to parse and interpret the rest of the file. It will enable both future changes to the specification and to support backward compatibility. The version number consists of a major and minor version indicator. The major field will be incremented when incompatible changes between versions are made (one or more sections are created, modified or deleted). The minor field will be incremented when backwards compatible changes are made.", + "type" : "string" + }, + "documentNamespace" : { + "type" : "string", + "description" : "The URI provides an unambiguous mechanism for other SPDX documents to reference SPDX elements within this SPDX document." + }, + "documentDescribes" : { + "description" : "Packages, files and/or Snippets described by this SPDX document", + "type" : "array", + "items" : { + "type" : "string", + "description" : "SPDX ID for each Package, File, or Snippet." + } + }, + "packages" : { + "description" : "Packages referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "attributionTexts" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "string" + } + }, + "builtDate" : { + "description" : "This field provides a place for recording the actual date the package was built.", + "type" : "string" + }, + "checksums" : { + "description" : "The checksum property provides a mechanism that can be used to verify that the contents of a File or Package have not changed.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "algorithm" : { + "description" : "Identifies the algorithm used to produce the subject Checksum. Currently, SHA-1 is the only supported algorithm. It is anticipated that other algorithms will be supported at a later time.", + "type" : "string", + "enum" : [ "SHA1", "BLAKE3", "SHA3-384", "SHA256", "SHA384", "BLAKE2b-512", "BLAKE2b-256", "SHA3-512", "MD2", "ADLER32", "MD4", "SHA3-256", "BLAKE2b-384", "SHA512", "MD6", "MD5", "SHA224" ] + }, + "checksumValue" : { + "description" : "The checksumValue property provides a lower case hexidecimal encoded digest value produced using a specific algorithm.", + "type" : "string" + } + }, + "required" : [ "algorithm", "checksumValue" ], + "additionalProperties" : false, + "description" : "A Checksum is value that allows the contents of a file to be authenticated. Even small changes to the content of the file will change its checksum. This class allows the results of a variety of checksum and cryptographic message digest algorithms to be represented." + } + }, + "comment" : { + "type" : "string" + }, + "copyrightText" : { + "description" : "The text of copyright declarations recited in the package, file or snippet.\n\nIf the copyrightText field is not present, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "description" : { + "description" : "Provides a detailed description of the package.", + "type" : "string" + }, + "downloadLocation" : { + "description" : "The URI at which this package is available for download. Private (i.e., not publicly reachable) URIs are acceptable as values of this property. The values http://spdx.org/rdf/terms#none and http://spdx.org/rdf/terms#noassertion may be used to specify that the package is not downloadable or that no attempt was made to determine its download location, respectively.", + "type" : "string" + }, + "externalRefs" : { + "description" : "An External Reference allows a Package to reference an external source of additional information, metadata, enumerations, asset identifiers, or downloadable content believed to be relevant to the Package.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "referenceCategory" : { + "description" : "Category for the external reference", + "type" : "string", + "enum" : [ "OTHER", "PERSISTENT-ID", "SECURITY", "PACKAGE-MANAGER" ] + }, + "referenceLocator" : { + "description" : "The unique string with no spaces necessary to access the package-specific information, metadata, or content within the target location. The format of the locator is subject to constraints defined by the .", + "type" : "string" + }, + "referenceType" : { + "description" : "Type of the external reference. These are definined in an appendix in the SPDX specification.", + "type" : "string" + } + }, + "required" : [ "referenceCategory", "referenceLocator", "referenceType" ], + "additionalProperties" : false, + "description" : "An External Reference allows a Package to reference an external source of additional information, metadata, enumerations, asset identifiers, or downloadable content believed to be relevant to the Package." + } + }, + "filesAnalyzed" : { + "description" : "Indicates whether the file content of this package has been available for or subjected to analysis when creating the SPDX document. If false indicates packages that represent metadata or URI references to a project, product, artifact, distribution or a component. If set to false, the package must not contain any files.", + "type" : "boolean" + }, + "hasFiles" : { + "description" : "Indicates that a particular file belongs to a package.", + "type" : "array", + "items" : { + "description" : "SPDX ID for File. Indicates that a particular file belongs to a package.", + "type" : "string" + } + }, + "homepage" : { + "type" : "string" + }, + "licenseComments" : { + "description" : "The licenseComments property allows the preparer of the SPDX document to describe why the licensing in spdx:licenseConcluded was chosen.", + "type" : "string" + }, + "licenseConcluded" : { + "description" : "License expression for licenseConcluded. See SPDX Annex D for the license expression syntax. The licensing that the preparer of this SPDX document has concluded, based on the evidence, actually applies to the SPDX Item.\n\nIf the licenseConcluded field is not present for an SPDX Item, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseDeclared" : { + "description" : "License expression for licenseDeclared. See SPDX Annex D for the license expression syntax. The licensing that the creators of the software in the package, or the packager, have declared. Declarations by the original software creator should be preferred, if they exist.", + "type" : "string" + }, + "licenseInfoFromFiles" : { + "description" : "The licensing information that was discovered directly within the package. There will be an instance of this property for each distinct value of alllicenseInfoInFile properties of all files contained in the package.\n\nIf the licenseInfoFromFiles field is not present for a package and filesAnalyzed property for that same pacakge is true or omitted, it implies an equivalent meaning to NOASSERTION.", + "type" : "array", + "items" : { + "description" : "License expression for licenseInfoFromFiles. See SPDX Annex D for the license expression syntax. The licensing information that was discovered directly within the package. There will be an instance of this property for each distinct value of alllicenseInfoInFile properties of all files contained in the package.\n\nIf the licenseInfoFromFiles field is not present for a package and filesAnalyzed property for that same pacakge is true or omitted, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + } + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "originator" : { + "description" : "The name and, optionally, contact information of the person or organization that originally created the package. Values of this property must conform to the agent and tool syntax.", + "type" : "string" + }, + "packageFileName" : { + "description" : "The base name of the package file name. For example, zlib-1.2.5.tar.gz.", + "type" : "string" + }, + "packageVerificationCode" : { + "type" : "object", + "properties" : { + "packageVerificationCodeExcludedFiles" : { + "description" : "A file that was excluded when calculating the package verification code. This is usually a file containing SPDX data regarding the package. If a package contains more than one SPDX file all SPDX files must be excluded from the package verification code. If this is not done it would be impossible to correctly calculate the verification codes in both files.", + "type" : "array", + "items" : { + "description" : "A file that was excluded when calculating the package verification code. This is usually a file containing SPDX data regarding the package. If a package contains more than one SPDX file all SPDX files must be excluded from the package verification code. If this is not done it would be impossible to correctly calculate the verification codes in both files.", + "type" : "string" + } + }, + "packageVerificationCodeValue" : { + "description" : "The actual package verification code as a hex encoded value.", + "type" : "string" + } + }, + "required" : [ "packageVerificationCodeValue" ], + "additionalProperties" : false, + "description" : "A manifest based verification code (the algorithm is defined in section 4.7 of the full specification) of the SPDX Item. This allows consumers of this data and/or database to determine if an SPDX item they have in hand is identical to the SPDX item from which the data was produced. This algorithm works even if the SPDX document is included in the SPDX item." + }, + "primaryPackagePurpose" : { + "description" : "This field provides information about the primary purpose of the identified package. Package Purpose is intrinsic to how the package is being used rather than the content of the package.", + "type" : "string", + "enum" : [ "OTHER", "INSTALL", "ARCHIVE", "FIRMWARE", "APPLICATION", "FRAMEWORK", "LIBRARY", "CONTAINER", "SOURCE", "DEVICE", "OPERATING_SYSTEM", "FILE" ] + }, + "releaseDate" : { + "description" : "This field provides a place for recording the date the package was released.", + "type" : "string" + }, + "sourceInfo" : { + "description" : "Allows the producer(s) of the SPDX document to describe how the package was acquired and/or changed from the original source.", + "type" : "string" + }, + "summary" : { + "description" : "Provides a short description of the package.", + "type" : "string" + }, + "supplier" : { + "description" : "The name and, optionally, contact information of the person or organization who was the immediate supplier of this package to the recipient. The supplier may be different than originator when the software has been repackaged. Values of this property must conform to the agent and tool syntax.", + "type" : "string" + }, + "validUntilDate" : { + "description" : "This field provides a place for recording the end of the support period for a package from the supplier.", + "type" : "string" + }, + "versionInfo" : { + "description" : "Provides an indication of the version of the package that is described by this SpdxDocument.", + "type" : "string" + } + }, + "required" : [ "SPDXID", "downloadLocation", "name" ], + "additionalProperties" : false + } + }, + "files" : { + "description" : "Files referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "artifactOfs" : { + "description" : "Indicates the project in which the SpdxElement originated. Tools must preserve doap:homepage and doap:name properties and the URI (if one is known) of doap:Project resources that are values of this property. All other properties of doap:Projects are not directly supported by SPDX and may be dropped when translating to or from some SPDX formats.", + "type" : "array", + "items" : { + "type" : "object" + } + }, + "attributionTexts" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "string" + } + }, + "checksums" : { + "description" : "The checksum property provides a mechanism that can be used to verify that the contents of a File or Package have not changed.", + "minItems" : 1, + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "algorithm" : { + "description" : "Identifies the algorithm used to produce the subject Checksum. Currently, SHA-1 is the only supported algorithm. It is anticipated that other algorithms will be supported at a later time.", + "type" : "string", + "enum" : [ "SHA1", "BLAKE3", "SHA3-384", "SHA256", "SHA384", "BLAKE2b-512", "BLAKE2b-256", "SHA3-512", "MD2", "ADLER32", "MD4", "SHA3-256", "BLAKE2b-384", "SHA512", "MD6", "MD5", "SHA224" ] + }, + "checksumValue" : { + "description" : "The checksumValue property provides a lower case hexidecimal encoded digest value produced using a specific algorithm.", + "type" : "string" + } + }, + "required" : [ "algorithm", "checksumValue" ], + "additionalProperties" : false, + "description" : "A Checksum is value that allows the contents of a file to be authenticated. Even small changes to the content of the file will change its checksum. This class allows the results of a variety of checksum and cryptographic message digest algorithms to be represented." + } + }, + "comment" : { + "type" : "string" + }, + "copyrightText" : { + "description" : "The text of copyright declarations recited in the package, file or snippet.\n\nIf the copyrightText field is not present, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "fileContributors" : { + "description" : "This field provides a place for the SPDX file creator to record file contributors. Contributors could include names of copyright holders and/or authors who may not be copyright holders yet contributed to the file content.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX file creator to record file contributors. Contributors could include names of copyright holders and/or authors who may not be copyright holders yet contributed to the file content.", + "type" : "string" + } + }, + "fileDependencies" : { + "description" : "This field is deprecated since SPDX 2.0 in favor of using Section 7 which provides more granularity about relationships.", + "type" : "array", + "items" : { + "description" : "SPDX ID for File. This field is deprecated since SPDX 2.0 in favor of using Section 7 which provides more granularity about relationships.", + "type" : "string" + } + }, + "fileName" : { + "description" : "The name of the file relative to the root of the package.", + "type" : "string" + }, + "fileTypes" : { + "description" : "The type of the file.", + "type" : "array", + "items" : { + "description" : "The type of the file.", + "type" : "string", + "enum" : [ "OTHER", "DOCUMENTATION", "IMAGE", "VIDEO", "ARCHIVE", "SPDX", "APPLICATION", "SOURCE", "BINARY", "TEXT", "AUDIO" ] + } + }, + "licenseComments" : { + "description" : "The licenseComments property allows the preparer of the SPDX document to describe why the licensing in spdx:licenseConcluded was chosen.", + "type" : "string" + }, + "licenseConcluded" : { + "description" : "License expression for licenseConcluded. See SPDX Annex D for the license expression syntax. The licensing that the preparer of this SPDX document has concluded, based on the evidence, actually applies to the SPDX Item.\n\nIf the licenseConcluded field is not present for an SPDX Item, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseInfoInFiles" : { + "description" : "Licensing information that was discovered directly in the subject file. This is also considered a declared license for the file.\n\nIf the licenseInfoInFile field is not present for a file, it implies an equivalent meaning to NOASSERTION.", + "type" : "array", + "items" : { + "description" : "License expression for licenseInfoInFile. See SPDX Annex D for the license expression syntax. Licensing information that was discovered directly in the subject file. This is also considered a declared license for the file.\n\nIf the licenseInfoInFile field is not present for a file, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + } + }, + "noticeText" : { + "description" : "This field provides a place for the SPDX file creator to record potential legal notices found in the file. This may or may not include copyright statements.", + "type" : "string" + } + }, + "required" : [ "SPDXID", "checksums", "fileName" ], + "additionalProperties" : false + } + }, + "snippets" : { + "description" : "Snippets referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "attributionTexts" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "string" + } + }, + "comment" : { + "type" : "string" + }, + "copyrightText" : { + "description" : "The text of copyright declarations recited in the package, file or snippet.\n\nIf the copyrightText field is not present, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseComments" : { + "description" : "The licenseComments property allows the preparer of the SPDX document to describe why the licensing in spdx:licenseConcluded was chosen.", + "type" : "string" + }, + "licenseConcluded" : { + "description" : "License expression for licenseConcluded. See SPDX Annex D for the license expression syntax. The licensing that the preparer of this SPDX document has concluded, based on the evidence, actually applies to the SPDX Item.\n\nIf the licenseConcluded field is not present for an SPDX Item, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseInfoInSnippets" : { + "description" : "Licensing information that was discovered directly in the subject snippet. This is also considered a declared license for the snippet.\n\nIf the licenseInfoInSnippet field is not present for a snippet, it implies an equivalent meaning to NOASSERTION.", + "type" : "array", + "items" : { + "description" : "License expression for licenseInfoInSnippet. See SPDX Annex D for the license expression syntax. Licensing information that was discovered directly in the subject snippet. This is also considered a declared license for the snippet.\n\nIf the licenseInfoInSnippet field is not present for a snippet, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + } + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "ranges" : { + "description" : "This field defines the byte range in the original host file (in X.2) that the snippet information applies to", + "minItems" : 1, + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "endPointer" : { + "type" : "object", + "properties" : { + "reference" : { + "description" : "SPDX ID for File", + "type" : "string" + }, + "offset" : { + "type" : "integer", + "description" : "Byte offset in the file" + }, + "lineNumber" : { + "type" : "integer", + "description" : "line number offset in the file" + } + }, + "required" : [ "reference" ], + "additionalProperties" : false + }, + "startPointer" : { + "type" : "object", + "properties" : { + "reference" : { + "description" : "SPDX ID for File", + "type" : "string" + }, + "offset" : { + "type" : "integer", + "description" : "Byte offset in the file" + }, + "lineNumber" : { + "type" : "integer", + "description" : "line number offset in the file" + } + }, + "required" : [ "reference" ], + "additionalProperties" : false + } + }, + "required" : [ "endPointer", "startPointer" ], + "additionalProperties" : false + } + }, + "snippetFromFile" : { + "description" : "SPDX ID for File. File containing the SPDX element (e.g. the file contaning a snippet).", + "type" : "string" + } + }, + "required" : [ "SPDXID", "name", "ranges", "snippetFromFile" ], + "additionalProperties" : false + } + }, + "relationships" : { + "description" : "Relationships referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "spdxElementId" : { + "type" : "string", + "description" : "Id to which the SPDX element is related" + }, + "comment" : { + "type" : "string" + }, + "relatedSpdxElement" : { + "description" : "SPDX ID for SpdxElement. A related SpdxElement.", + "type" : "string" + }, + "relationshipType" : { + "description" : "Describes the type of relationship between two SPDX elements.", + "type" : "string", + "enum" : [ "VARIANT_OF", "COPY_OF", "PATCH_FOR", "TEST_DEPENDENCY_OF", "CONTAINED_BY", "DATA_FILE_OF", "OPTIONAL_COMPONENT_OF", "ANCESTOR_OF", "GENERATES", "CONTAINS", "OPTIONAL_DEPENDENCY_OF", "FILE_ADDED", "REQUIREMENT_DESCRIPTION_FOR", "DEV_DEPENDENCY_OF", "DEPENDENCY_OF", "BUILD_DEPENDENCY_OF", "DESCRIBES", "PREREQUISITE_FOR", "HAS_PREREQUISITE", "PROVIDED_DEPENDENCY_OF", "DYNAMIC_LINK", "DESCRIBED_BY", "METAFILE_OF", "DEPENDENCY_MANIFEST_OF", "PATCH_APPLIED", "RUNTIME_DEPENDENCY_OF", "TEST_OF", "TEST_TOOL_OF", "DEPENDS_ON", "SPECIFICATION_FOR", "FILE_MODIFIED", "DISTRIBUTION_ARTIFACT", "AMENDS", "DOCUMENTATION_OF", "GENERATED_FROM", "STATIC_LINK", "OTHER", "BUILD_TOOL_OF", "TEST_CASE_OF", "PACKAGE_OF", "DESCENDANT_OF", "FILE_DELETED", "EXPANDED_FROM_ARCHIVE", "DEV_TOOL_OF", "EXAMPLE_OF" ] + } + }, + "required" : [ "spdxElementId", "relatedSpdxElement", "relationshipType" ], + "additionalProperties" : false + } + } + }, + "required" : [ "SPDXID", "creationInfo", "dataLicense", "name", "spdxVersion" ], + "additionalProperties" : false +} \ No newline at end of file diff --git a/crates/trusted-server-release/src/documents.rs b/crates/trusted-server-release/src/documents.rs new file mode 100644 index 000000000..433dd3517 --- /dev/null +++ b/crates/trusted-server-release/src/documents.rs @@ -0,0 +1,765 @@ +use std::collections::{BTreeMap, BTreeSet}; +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 jsonschema::Draft; +use serde::Deserialize; +use serde::Serialize; +use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::ser::{SerializeMap as _, SerializeSeq as _}; +use serde_json::{Map, Number, Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; + +/// R-INDEX companion role names and their required `` suffixes. +const COMPANION_SUFFIXES: [(&str, &str); 6] = [ + ("sbom", ".spdx.json"), + ("provenance", ".provenance.sigstore.json"), + ("sbom_attestation", ".sbom.sigstore.json"), + ("build_evidence", ".build-evidence.json"), + ("build_recipe", ".build-recipe.json"), + ("rebuild_check", ".rebuild-check.json"), +]; + +/// Byte-limit class selected before a document is read or parsed. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Role { + Metadata, + Source, + Companion, + Bundle, + Spdx, + Executable, + /// One Cargo `--message-format=json` line. + CargoMessage, + /// Internal Cargo observations; never a published metadata contract. + CargoObservation, +} + +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 | Self::Bundle => 32 * 1024 * 1024, + Self::Spdx | Self::CargoObservation => 16 * 1024 * 1024, + Self::Executable => 512 * 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(()) +} + +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 FlatName<'a>(&'a str); + +/// Returns whether `name` is a safe flat release asset name. +/// +/// Rejects path separators, `..`, empty segments, control characters +/// (including newlines) and anything outside the governing schema pattern. +pub(crate) fn is_safe_flat_name(name: &str) -> bool { + safe_name(name).is_ok() +} + +fn safe_name(name: &str) -> Result, Report> { + let mut parts = name.split('.'); + let first = parts.next().unwrap_or_default(); + if name.len() > 160 + || !first + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || std::iter::once(first).chain(parts).any(|part| { + part.is_empty() + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) + { + return Err(invalid("unsafe flat filename")); + } + Ok(FlatName(name)) +} + +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) +} + +// The caller must select a trusted parent directory; Task 3 owns source/path policy. +fn atomic_json(path: &Path, value: &Value, role: Role) -> Result<(), Report> { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| invalid("output needs a UTF-8 flat filename"))?; + let name = safe_name(name)?; + let bytes = canonical_json(value, role)?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(parent) + .map_err(invalid)?; + let destination = parent.join(name.0); + match std::fs::symlink_metadata(&destination) { + Ok(metadata) if !metadata.is_file() || metadata.nlink() != 1 => { + return Err(invalid("output must be an unlinked regular file or absent")); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(invalid(error)), + } + let mut temporary = tempfile::Builder::new() + .prefix(".release-") + .tempfile_in(parent) + .map_err(invalid)?; + temporary + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(invalid)?; + temporary.write_all(&bytes).map_err(invalid)?; + temporary.as_file().sync_all().map_err(invalid)?; + let held = directory.metadata().map_err(invalid)?; + let current = std::fs::metadata(parent).map_err(invalid)?; + if (held.dev(), held.ino()) != (current.dev(), current.ino()) { + return Err(invalid("output parent changes before replacement")); + } + commit_output(temporary, &destination, &directory) +} + +fn commit_output( + temporary: tempfile::NamedTempFile, + path: &Path, + directory: &File, +) -> Result<(), Report> { + temporary + .persist(path) + .map_err(|error| invalid(error.error))?; + directory.sync_all().map_err(invalid) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FileRecord { + name: String, + size: u64, + sha256: String, +} + +fn validate_file_records(values: &[Value], role: Role) -> Result<(), Report> { + let mut names = BTreeSet::new(); + for value in values { + let record = FileRecord::deserialize(value).map_err(invalid)?; + record.check(role, &mut names)?; + } + Ok(()) +} + +impl FileRecord { + fn check(&self, role: Role, names: &mut BTreeSet) -> Result<(), Report> { + safe_name(&self.name)?; + if self.size > role.limit() + || !lower_hex(&self.sha256, 64) + || !names.insert(self.name.to_ascii_lowercase()) + { + return Err(invalid( + "file record has invalid size, digest or duplicate identity", + )); + } + Ok(()) + } + + fn fields(value: &Value) -> Result> { + Ok(Self { + name: value["name"] + .as_str() + .ok_or_else(|| invalid("missing file name"))? + .to_owned(), + size: value["size"] + .as_u64() + .ok_or_else(|| invalid("file length must be a nonnegative integer token"))?, + sha256: value["sha256"] + .as_str() + .ok_or_else(|| invalid("missing file digest"))? + .to_owned(), + }) + } +} + +/// Returns whether `value` is exactly `length` lowercase hexadecimal digits. +pub(crate) 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)) +} + +#[derive(Clone, Copy)] +enum KnownSchema { + ReleaseIndex, + Qualification, + Spdx, +} + +impl KnownSchema { + fn bytes(&self) -> &'static [u8] { + match self { + Self::ReleaseIndex => include_bytes!( + "../../../docs/superpowers/specs/schemas/release-index-v1.schema.json" + ), + Self::Qualification => { + include_bytes!("../../../scripts/release/qualification/results.schema.json") + } + Self::Spdx => include_bytes!("../schemas/spdx-2.3.schema.json"), + } + } + fn draft(&self) -> Draft { + match self { + Self::ReleaseIndex | Self::Qualification => Draft::Draft202012, + Self::Spdx => Draft::Draft7, + } + } +} + +fn schema_accepts( + schema: &Value, + draft: Draft, + value: &Value, +) -> Result> { + let validator = jsonschema::options() + .with_draft(draft) + .offline() + .build(schema) + .map_err(invalid)?; + Ok(validator.is_valid(value)) +} + +/// Validates SPDX document structure against the retained, pinned SPDX 2.3 schema. +/// +/// Schema validity establishes no artifact identity, contributor coverage or +/// release authority; the producer and consumer check those separately. +/// +/// # Errors +/// +/// Rejects a changed retained schema or a structurally invalid document. +pub(crate) fn validate_spdx(value: &Value) -> Result<(), Report> { + validate_schema(KnownSchema::Spdx, value) +} + +/// Validates a release index against the retained governing v1 schema. +/// +/// Also applies the index role checks: safe integer tokens, safe flat and +/// case-insensitively unique file names, role byte bounds and role-derived +/// companion names. Schema validity grants no signing or release authority. +/// +/// # Errors +/// +/// Rejects a structurally invalid index or a failed role check. +pub(crate) fn validate_release_index(value: &Value) -> Result<(), Report> { + validate_schema(KnownSchema::ReleaseIndex, value) +} + +/// Validates one index artifact entry against the retained schema's +/// `#/$defs/artifact` definition, built from the same retained raw bytes. +/// +/// # Errors +/// +/// Rejects an entry outside its fixed per-asset identity row, an unknown +/// field or capability, or a non-safe integer token. +pub(crate) fn validate_release_index_artifact(value: &Value) -> Result<(), Report> { + let retained = parse_json(KnownSchema::ReleaseIndex.bytes(), Role::Metadata)?; + let schema = json!({"$defs": retained["$defs"], "$ref": "#/$defs/artifact"}); + if !schema_accepts(&schema, Draft::Draft202012, value)? { + return Err(invalid( + "index artifact does not match the retained artifact definition", + )); + } + validate_safe_integers(value) +} + +fn validate_schema(schema: KnownSchema, value: &Value) -> Result<(), Report> { + let retained = if matches!(schema, KnownSchema::Spdx) { + parse_pinned_spdx_schema(schema.bytes())? + } else { + parse_json(schema.bytes(), Role::Metadata)? + }; + if !schema_accepts(&retained, schema.draft(), value)? { + return Err(invalid("document does not match retained schema")); + } + if matches!( + schema, + KnownSchema::ReleaseIndex | KnownSchema::Qualification + ) { + validate_safe_integers(value)?; + } + if matches!(schema, KnownSchema::ReleaseIndex) { + check_index_files(value)?; + } + Ok(()) +} + +/// Checks every index file record and requires role-derived companion names. +fn check_index_files(value: &Value) -> Result<(), Report> { + let mut names = BTreeSet::new(); + FileRecord::fields(&value["tsjs_inventory"])?.check(Role::Metadata, &mut names)?; + for artifact in value["artifacts"] + .as_array() + .ok_or_else(|| invalid("missing artifacts"))? + { + let asset = FileRecord::fields(artifact)?; + asset.check(Role::Executable, &mut names)?; + for (kind, companion) in artifact["companions"] + .as_object() + .ok_or_else(|| invalid("missing companions"))? + { + let suffix = COMPANION_SUFFIXES + .iter() + .find(|(role, _)| role == kind) + .map(|(_, suffix)| *suffix) + .ok_or_else(|| invalid("unknown companion role"))?; + let derived = companion["name"] + .as_str() + .and_then(|name| name.strip_prefix(asset.name.as_str())); + if derived != Some(suffix) { + return Err(invalid( + "companion name must be the asset name plus its role suffix", + )); + } + let role = if kind == "sbom" { + Role::Spdx + } else { + Role::Companion + }; + FileRecord::fields(companion)?.check(role, &mut names)?; + } + } + Ok(()) +} + +fn parse_pinned_spdx_schema(bytes: &[u8]) -> Result> { + if bytes.len() != 45_312 + || hex::encode(Sha256::digest(bytes)) + != "239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b" + { + return Err(invalid( + "retained SPDX schema raw identity does not match pinned source", + )); + } + let value = parse_json(bytes, Role::Metadata)?; + if value["$schema"] != "http://json-schema.org/draft-07/schema#" { + return Err(invalid("retained SPDX schema must declare Draft 7")); + } + Ok(value) +} + +fn validate_safe_integers(value: &Value) -> Result<(), Report> { + match value { + Value::Number(number) + if number + .as_u64() + .is_none_or(|integer| integer > 9_007_199_254_740_991) => + { + return Err(invalid( + "closed role requires nonnegative safe integer tokens", + )); + } + Value::Object(values) => { + for value in values.values() { + validate_safe_integers(value)?; + } + } + Value::Array(values) => { + for value in values { + validate_safe_integers(value)?; + } + } + _ => {} + } + Ok(()) +} + +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 000000000..e3d48ebe8 --- /dev/null +++ b/crates/trusted-server-release/src/documents/tests.rs @@ -0,0 +1,883 @@ +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::Bundle, 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::Bundle, + 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_unsafe_flat_names() { + for name in [ + "", ".", "..", ".hidden", "../a", "a/b", "a\\b", "a..b", "a.", "a b", "a\n", "a\0", "é", + "-a", "a:b", + ] { + assert!( + safe_name(name).is_err(), + "should reject unsafe name {name:?}" + ); + } + assert!( + safe_name(&"a".repeat(161)).is_err(), + "should reject long name" + ); + for name in [ + "a", + "ts-x86_64-unknown-linux-gnu", + "file.spdx.json", + "A_0-z", + ] { + assert!(safe_name(name).is_ok(), "should accept safe flat name"); + } +} + +#[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 rejects_oversized_serialization_before_replacement() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("output.json"); + fs::write(&path, b"old").expect("should write previous document"); + let value = json!({"large":"x".repeat(Role::Metadata.limit() as usize)}); + assert!( + atomic_json(&path, &value, Role::Metadata).is_err(), + "should reject oversized output" + ); + assert_eq!( + fs::read(&path).expect("should retain old file"), + b"old", + "should preserve old bytes on rejected output" + ); + assert_eq!( + fs::read_dir(root.path()) + .expect("should list fixtures") + .count(), + 1, + "should leave no temporary output" + ); +} + +#[test] +fn atomic_output_replaces_inode_with_private_mode() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("output.json"); + fs::write(&path, b"old").expect("should write previous document"); + let old = fs::metadata(&path).expect("should inspect old file").ino(); + atomic_json(&path, &json!({"answer":42}), Role::Metadata) + .expect("should replace complete document"); + let new = fs::metadata(&path).expect("should inspect replaced file"); + assert_ne!( + old, + new.ino(), + "should replace rather than truncate original inode" + ); + assert_eq!( + new.mode() & 0o777, + 0o600, + "should install private 0600 output" + ); + assert_eq!( + fs::read(&path).expect("should read complete output"), + b"{\"answer\":42}\n", + "should install full bytes" + ); +} + +#[test] +fn rejects_output_links_and_cleans_failed_replacement() { + let root = tempdir().expect("should create fixture"); + let real = root.path().join("real"); + fs::write(&real, b"old").expect("should write linked target"); + let link = root.path().join("link"); + symlink(&real, &link).expect("should create symbolic output"); + assert!( + atomic_json(&link, &json!({}), Role::Metadata).is_err(), + "should reject symbolic output" + ); + let hard = root.path().join("hard"); + fs::hard_link(&real, &hard).expect("should create hardlinked output"); + assert!( + atomic_json(&hard, &json!({}), Role::Metadata).is_err(), + "should reject hardlinked output" + ); + let directory = root.path().join("directory"); + fs::create_dir(&directory).expect("should create invalid destination"); + assert!( + atomic_json(&directory, &json!({}), Role::Metadata).is_err(), + "should reject directory output" + ); + assert_eq!( + fs::read(&real).expect("should retain original target"), + b"old", + "should preserve linked target bytes" + ); + assert_eq!( + fs::read_dir(root.path()) + .expect("should inspect cleanup") + .count(), + 4, + "should remove every failed sibling output" + ); +} + +#[test] +fn rejects_negative_noninteger_and_out_of_role_lengths() { + for size in [ + json!(-1), + json!(1.0), + json!(1.5), + json!("1"), + json!(9007199254740992_u64), + json!(Role::Metadata.limit() + 1), + ] { + let record = json!({"name":"fixture.json", "size":size, "sha256":"a".repeat(64)}); + assert!( + validate_file_records(&[record], Role::Metadata).is_err(), + "should reject invalid length representation or bounds" + ); + } + for size in [0, 1, Role::Metadata.limit()] { + assert!( + validate_file_records( + &[json!({"name":"fixture.json", "size":size, "sha256":"a".repeat(64)})], + Role::Metadata + ) + .is_ok(), + "should accept exact valid role boundary" + ); + } +} + +#[test] +fn rejects_ambiguous_duplicate_file_identities() { + for names in [["same.json", "same.json"], ["File.json", "file.json"]] { + let records = names.map(|name| json!({"name":name,"size":1,"sha256":"a".repeat(64)})); + assert!( + validate_file_records(&records, Role::Metadata).is_err(), + "should reject duplicate identity including portable case collisions" + ); + } +} + +#[test] +fn rejects_invalid_file_record_fields() { + for record in [ + json!({"name":"../file", "size":1, "sha256":"a".repeat(64)}), + json!({"name":"file", "size":1, "sha256":"A".repeat(64)}), + json!({"name":"file", "size":1, "sha256":"a".repeat(64), "extra":true}), + ] { + assert!( + validate_file_records(&[record], Role::Metadata).is_err(), + "should reject unsafe, malformed or extra fields" + ); + } +} + +#[test] +fn draft_202012_prefix_items_and_items_are_enforced() { + let schema = + json!({"type":"array","prefixItems":[{"const":"first"},{"type":"integer"}],"items":false}); + assert!( + schema_accepts(&schema, Draft::Draft202012, &json!(["first", 2])) + .expect("should compile explicit 2020-12 schema"), + "should accept prefix items" + ); + for value in [ + json!(["wrong", 2]), + json!(["first", "wrong"]), + json!(["first", 2, 3]), + ] { + assert!( + !schema_accepts(&schema, Draft::Draft202012, &value).expect("should compile schema"), + "should reject invalid prefix or additional item" + ); + } +} + +#[test] +fn refuses_unregistered_remote_and_file_references() { + for reference in [ + "https://example.com/unregistered.json", + "http://example.com/unregistered.json", + "file:///tmp/unregistered-schema.json", + ] { + assert!( + schema_accepts(&json!({"$ref":reference}), Draft::Draft202012, &json!(null)).is_err(), + "should refuse unregistered references without retrieval" + ); + } +} + +#[test] +fn refuses_existing_local_reference_even_with_file_resolution_feature() { + let root = tempdir().expect("should create local schema fixture"); + let path = root.path().join("unregistered.json"); + fs::write( + &path, + serde_json::to_vec(&json!({"type":"null"})) + .expect("should serialize fictional local schema"), + ) + .expect("should write local schema"); + let reference = format!("file://{}", path.display()); + let result = schema_accepts(&json!({"$ref":reference}), Draft::Draft202012, &Value::Null); + assert!( + result.is_err(), + "should refuse existing file without reading an unregistered reference" + ); +} + +#[test] +fn retained_spdx_identity_and_draft7_are_exact() { + let bytes = KnownSchema::Spdx.bytes(); + assert_eq!( + bytes.len(), + 45312, + "should retain exact original schema length" + ); + assert_eq!( + hex::encode(Sha256::digest(bytes)), + "239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b", + "should retain pinned original schema hash" + ); + let value = parse(bytes).expect("should parse official retained schema"); + assert_eq!( + value["$schema"], "http://json-schema.org/draft-07/schema#", + "should use official Draft 7 dialect" + ); +} + +#[test] +fn rejects_altered_spdx_schema_before_evaluation() { + let original = KnownSchema::Spdx.bytes(); + let mut altered = original.to_vec(); + let title = altered + .windows(8) + .position(|bytes| bytes == b"SPDX 2.3") + .expect("should find official schema title"); + altered[title] = b'T'; + assert!( + parse_pinned_spdx_schema(&altered).is_err(), + "should reject altered but parseable retained schema" + ); + let mut extended = original.to_vec(); + extended.push(b'\n'); + assert!( + parse_pinned_spdx_schema(&extended).is_err(), + "should reject changed raw byte count before use" + ); + assert!( + parse_pinned_spdx_schema(original).is_ok(), + "should accept exact retained schema identity and dialect" + ); +} + +#[test] +fn official_spdx_schema_checks_structure_only() { + let valid = json!({"SPDXID":"SPDXRef-DOCUMENT", "spdxVersion":"SPDX-2.3", "creationInfo":{"creators":["Tool: fictional-fixture"],"created":"2026-01-01T00:00:00Z"}, "name":"Fictional fixture", "dataLicense":"CC0-1.0", "documentNamespace":"https://example.com/spdx/fictional-fixture"}); + assert!( + validate_schema(KnownSchema::Spdx, &valid).is_ok(), + "should accept a structural toy fixture only" + ); + for value in [json!({}), json!({"SPDXID":42}), { + let mut invalid = valid.clone(); + invalid["packages"] = json!([{"SPDXID":"SPDXRef-Package","name":"fictional"}]); + invalid + }] { + assert!( + validate_schema(KnownSchema::Spdx, &value).is_err(), + "should reject structurally incomplete SPDX" + ); + } +} + +#[test] +fn retained_governing_schemas_reject_missing_fields() { + for schema in [KnownSchema::ReleaseIndex, KnownSchema::Qualification] { + assert!( + validate_schema(schema, &json!({})).is_err(), + "should apply retained governing required fields" + ); + } +} + +#[test] +fn retained_qualification_schema_accepts_structural_observation_only() { + let mut value = json!({ + "schema_version":1, "purpose":"disposable-rehearsal-observation", "source_commit":"a".repeat(40), + "closure_sha256":"a".repeat(64), "selection_sha256":"a".repeat(64), "raw_tools_sha256":"a".repeat(64), "dependency_sha256":"a".repeat(64), + "run":{"repository_id":"1","owner_id":"1","run_id":"1","attempt":1,"job":"fictional-fixture","runner":"github-hosted"}, + "host":{"environment_id":"macos15-aarch64","kind":"github-hosted-macos","architecture":"aarch64","kernel_architecture":"aarch64", + "os_version":"fictional-fixture","translated":false,"runner_image_version":null,"oci_index_sha256":null,"oci_manifest_sha256":null, + "glibc_version":null,"identity_log_sha256":"a".repeat(64)}, + "sdk":{"kind":"apple-sdk","version":"fictional","build_version":"fictional","tree_sha256":"a".repeat(64),"vendor_evidence_sha256":"a".repeat(64)}, + "tools":[], + "recipe":{"id":"tsjs","boundary_sha256":"a".repeat(64),"preparation_log_sha256":"a".repeat(64),"compile_log_sha256":"a".repeat(64), + "native_log_sha256":"a".repeat(64),"exit_code":1,"full_recipe":false,"same_boundary":false,"executable_sha256":"a".repeat(64), + "native_materials_sha256":"a".repeat(64),"deployment_target":null,"glibc_symbols":[],"unconfined":[],"confined":[], + "download_attempts":{"cargo":"unavailable","npm":"unavailable","missing_tool":"unavailable"}}, + "runtime":[] + }); + assert!( + validate_schema(KnownSchema::Qualification, &value).is_ok(), + "should accept structural observation fixture without qualifying a release" + ); + value["run"]["attempt"] = json!(1.0); + assert!( + validate_schema(KnownSchema::Qualification, &value).is_err(), + "should reject float representation of integer observation" + ); + value["run"]["attempt"] = json!(1); + value["purpose"] = json!("production-qualified"); + assert!( + validate_schema(KnownSchema::Qualification, &value).is_err(), + "should enforce retained observation purpose" + ); +} + +fn index_fixture() -> Value { + let identities = [ + ( + "trusted-server-fastly.wasm", + "wasm32-wasip1", + "wasm32", + "fastly-compute", + "wasip1", + ), + ( + "ts-x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + "x86_64", + "linux-gnu", + "glibc-2.34", + ), + ( + "ts-aarch64-apple-darwin", + "aarch64-apple-darwin", + "aarch64", + "macos", + "13.0", + ), + ( + "ts-x86_64-apple-darwin", + "x86_64-apple-darwin", + "x86_64", + "macos", + "13.0", + ), + ]; + let artifacts: Vec<_> = identities.iter().map(|(name,target,architecture,runtime,minimum)| { + let is_server = *runtime == "fastly-compute"; + let companions: Map<_,_> = [("sbom", ".spdx.json"), ("provenance", ".provenance.sigstore.json"), ("sbom_attestation", ".sbom.sigstore.json"), ("build_evidence", ".build-evidence.json"), ("build_recipe", ".build-recipe.json"), ("rebuild_check", ".rebuild-check.json")].iter() + .map(|(role, suffix)| (role.to_string(), json!({"name":format!("{name}{suffix}"), "size":1, "sha256":"a".repeat(64)}))).collect(); + json!({"name":name, "size":1, "sha256":"a".repeat(64), "role":if is_server {"server"} else {"operator-cli"}, + "kind":if is_server {"wasm-core"} else {"native-executable"}, "package":if is_server {"trusted-server-adapter-fastly"} else {"trusted-server-cli"}, + "target":target, "variant":"default", "assurance_profile":if is_server {"provenance-sbom-repeatable"} else {"provenance-sbom"}, + "capabilities":if is_server {vec!["fastly-production"]} else {vec!["cli-execute"]}, + "compatibility":{"runtime":runtime,"minimum_version":minimum,"architecture":architecture}, + "js_inventory_sha256":"a".repeat(64), "companions":companions}) + }).collect(); + let schema = + parse(KnownSchema::ReleaseIndex.bytes()).expect("should load retained index schema"); + json!({"schema_version":1,"repository":schema["properties"]["repository"]["const"],"tag":"v1.0.0", + "source_commit":"a".repeat(40),"recipe_source_commit":"a".repeat(40),"cohort_source_commit":"a".repeat(40), + "cohort":"phase1-fastly-cli", "policy_version":1,"policy_sha256":"a".repeat(64), + "tsjs_inventory":{"name":"tsjs-inventory.json","size":1,"sha256":"a".repeat(64)},"artifacts":artifacts}) +} + +#[test] +fn retained_index_schema_accepts_only_structural_fixed_cohort() { + let value = index_fixture(); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &value).is_ok(), + "should accept structural fixed-cohort fixture only" + ); + let mut extra = value.clone(); + extra["artifacts"] + .as_array_mut() + .expect("should access artifacts") + .push(value["artifacts"][0].clone()); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &extra).is_err(), + "should reject fifth artifact" + ); + let mut wrong = value; + wrong["artifacts"][1]["target"] = json!("aarch64-apple-darwin"); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &wrong).is_err(), + "should reject mismatched fixed cohort identity" + ); +} + +#[test] +fn index_role_checks_reject_fractional_lengths_and_duplicate_companions() { + let good = index_fixture(); + for size in [ + json!(-1), + json!(1.0), + json!(1.5), + json!(9007199254740992_u64), + ] { + let mut value = good.clone(); + value["artifacts"][0]["companions"]["sbom"]["size"] = size; + assert!( + validate_schema(KnownSchema::ReleaseIndex, &value).is_err(), + "should reject noninteger or unsafe lengths after schema checks" + ); + } + let mut duplicate = good; + duplicate["artifacts"][1]["companions"]["sbom"]["name"] = + duplicate["artifacts"][0]["companions"]["sbom"]["name"].clone(); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &duplicate).is_err(), + "should reject duplicate companion identities across artifacts" + ); +} + +#[test] +fn index_role_checks_reject_unsafe_policy_integer() { + let mut value = index_fixture(); + value["policy_version"] = json!(9007199254740992_u64); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &value).is_err(), + "should reject integers above interoperable safe limit" + ); + value["policy_version"] = json!(1.0); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &value).is_err(), + "should require integer token representation" + ); +} + +#[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" + ); +} + +#[test] +fn failed_rename_removes_private_sibling() { + let root = tempdir().expect("should create fixture"); + let destination = root.path().join("directory"); + fs::create_dir(&destination).expect("should create conflicting destination"); + let directory = File::open(root.path()).expect("should hold parent"); + let temporary = tempfile::Builder::new() + .prefix(".release-") + .tempfile_in(root.path()) + .expect("should create sibling"); + let temporary_path = temporary.path().to_owned(); + assert!( + commit_output(temporary, &destination, &directory).is_err(), + "should fail file-to-directory replacement" + ); + assert!( + !temporary_path.exists(), + "should clean sibling after actual rename failure" + ); + assert_eq!( + fs::read_dir(root.path()) + .expect("should inspect cleanup") + .count(), + 1, + "should leave only existing destination" + ); +} + +#[test] +fn index_role_checks_require_role_specific_companion_names() { + let good = index_fixture(); + let mut swapped = good.clone(); + let sbom = swapped["artifacts"][0]["companions"]["sbom"]["name"].clone(); + swapped["artifacts"][0]["companions"]["sbom"]["name"] = + swapped["artifacts"][0]["companions"]["provenance"]["name"].clone(); + swapped["artifacts"][0]["companions"]["provenance"]["name"] = sbom; + assert!( + validate_schema(KnownSchema::ReleaseIndex, &swapped).is_err(), + "should reject companion names swapped between roles" + ); + let mut foreign = good; + foreign["artifacts"][0]["companions"]["sbom"]["name"] = + json!("ts-x86_64-unknown-linux-gnu-other.spdx.json"); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &foreign).is_err(), + "should reject companion names not derived from their asset" + ); +} diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs new file mode 100644 index 000000000..358f70a7f --- /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 invocation; Rust release execution remains disabled pending \ + authenticated bootstrap and preflight" + )] + Usage, + #[display("Invalid release document")] + Document, + #[cfg(test)] + #[display("Release source is not authenticated")] + Source, + #[display("Git object verification failed")] + GitObject, + #[display("Bounded release child process failed")] + Process, +} + +impl core::error::Error for ReleaseError {} diff --git a/crates/trusted-server-release/src/git_objects.rs b/crates/trusted-server-release/src/git_objects.rs new file mode 100644 index 000000000..7002bdb58 --- /dev/null +++ b/crates/trusted-server-release/src/git_objects.rs @@ -0,0 +1,394 @@ +//! Collision-checked Git objects under fixed source and Cargo material budgets. + +use std::cell::Cell; +use std::io::{self, BufRead, Cursor, Read as _}; + +use error_stack::Report; + +use crate::error::ReleaseError; + +fn object_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::GitObject).attach(detail.to_string()) +} + +/// A shared byte budget across every request in one object collection. +/// +/// These checks authenticate bytes, not a Git executable or installed checkout. +pub(crate) struct ObjectReader { + object_limit: u64, + remaining: Cell, +} + +impl ObjectReader { + /// Creates the source-checkout policy: 64 MiB per object, 2 GiB total. + pub(crate) fn source() -> Self { + Self { + object_limit: 64 * 1024 * 1024, + remaining: Cell::new(2 * 1024 * 1024 * 1024), + } + } + + /// Creates one Cargo collection policy: 32 MiB per object, 512 MiB total. + pub(crate) fn cargo_materials() -> Self { + Self { + object_limit: 32 * 1024 * 1024, + remaining: Cell::new(512 * 1024 * 1024), + } + } + + #[cfg(test)] + /// Lowers a test's budget without allocating production-sized bodies. + pub(crate) fn set_budget_for_tests(&self, bytes: u64) { + self.remaining.set(bytes); + } + + /// Preflights sizes, reads bounded batches and delivers only rehashed bytes. + /// + /// The caller's executor must enforce each supplied output bound and its + /// shared deadline/cancellation policy. This reader does not authenticate + /// the executable or supervise processes itself. + /// + /// # Errors + /// + /// Returns an error for failed commands, malformed or inconsistent replies, + /// exceeded byte budgets, identity mismatches or detected SHA-1 collisions. + pub(crate) fn read_objects( + &self, + kind: &'static str, + oids: &[&str], + execute: &mut impl FnMut(&[&str], Vec, u64) -> Result, Report>, + mut visit: impl FnMut(usize, &[u8]) -> Result<(), Report>, + ) -> Result<(), Report> { + let sizes = self.object_sizes(kind, oids, execute)?; + let mut start = 0; + while start < oids.len() { + let mut end = start; + let mut limit = 0; + while end < oids.len() && limit + sizes[end] + 128 <= self.object_limit + 128 { + limit += sizes[end] + 128; + end += 1; + } + let output = execute(&["cat-file", "--batch"], request(&oids[start..end]), limit)?; + let mut reader = Cursor::new(output.as_slice()); + read_batch( + &mut reader, + &Batch { + kind, + object_limit: self.object_limit, + oids: &oids[start..end], + sizes: &sizes[start..end], + budget: &self.remaining, + }, + &mut |index, bytes| visit(start + index, bytes), + )?; + if reader.position() != output.len() as u64 { + return Err(object_error("unexpected trailing Git batch output")); + } + start = end; + } + Ok(()) + } + + fn object_sizes( + &self, + kind: &str, + oids: &[&str], + execute: &mut impl FnMut(&[&str], Vec, u64) -> Result, Report>, + ) -> Result, Report> { + let limit = (oids.len() as u64) + .checked_mul(128) + .ok_or_else(|| object_error("object header budget overflow"))?; + let output = execute(&["cat-file", "--batch-check"], request(oids), limit)?; + let mut reader = Cursor::new(output.as_slice()); + let mut total = 0_u64; + let mut sizes = Vec::with_capacity(oids.len()); + for oid in oids { + let size = read_header(&mut reader, kind, oid)?; + total = total + .checked_add(size) + .ok_or_else(|| object_error("object byte budget overflow"))?; + if size > self.object_limit || total > self.remaining.get() { + return Err(object_error( + "object exceeds the per-object or aggregate object byte budget", + )); + } + sizes.push(size); + } + if reader.position() != output.len() as u64 { + return Err(object_error("unexpected trailing Git object metadata")); + } + Ok(sizes) + } +} + +fn request(oids: &[&str]) -> Vec { + oids.iter() + .flat_map(|oid| oid.bytes().chain(std::iter::once(b'\n'))) + .collect() +} + +/// Collision-checked Git SHA-1 object id of `bytes` stored as an object of `kind`. +fn object_id(kind: &str, bytes: &[u8]) -> Result> { + let header = format!("{kind} {}\0", bytes.len()); + checked_sha1(&[header.as_bytes(), bytes]) +} + +/// One `cat-file --batch` request and the shared aggregate byte budget. +struct Batch<'a> { + kind: &'a str, + object_limit: u64, + oids: &'a [&'a str], + sizes: &'a [u64], + budget: &'a Cell, +} + +fn read_batch( + reader: &mut impl BufRead, + batch: &Batch<'_>, + visit: &mut impl FnMut(usize, &[u8]) -> Result<(), Report>, +) -> Result<(), Report> { + let io_error = |error: io::Error| object_error(format!("cannot read batch output: {error}")); + let kind = batch.kind; + if batch.sizes.len() != batch.oids.len() { + return Err(object_error( + "Git preflight size count differs from requested object count", + )); + } + for (index, oid) in batch.oids.iter().enumerate() { + let size = read_header(reader, kind, oid)?; + if size != batch.sizes[index] { + return Err(object_error( + "Git object body size differs from preflight metadata", + )); + } + let remaining = batch.budget.get(); + if size > batch.object_limit || size > remaining { + return Err(object_error(format!( + "object {oid} exceeds the per-object or aggregate object byte budget" + ))); + } + batch.budget.set(remaining - size); + let length = usize::try_from(size).map_err(|_| object_error("object size overflows"))?; + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).map_err(io_error)?; + let mut terminator = [0]; + reader.read_exact(&mut terminator).map_err(io_error)?; + if terminator != *b"\n" { + return Err(object_error(format!("batch output for {oid} is malformed"))); + } + if object_id(kind, &bytes)? != *oid { + return Err(object_error(format!( + "object identity mismatch: {kind} {oid} content does not hash to its id" + ))); + } + visit(index, &bytes)?; + } + Ok(()) +} + +/// Reads one ` ` header and returns the object size. +fn read_header( + reader: &mut impl BufRead, + kind: &str, + oid: &str, +) -> Result> { + let mut header = Vec::new(); + reader + .take(128) + .read_until(b'\n', &mut header) + .map_err(|error| object_error(format!("cannot read batch header: {error}")))?; + let header = + std::str::from_utf8(&header).map_err(|_| object_error("batch header is not UTF-8"))?; + let fields: Vec<&str> = header.trim_end_matches('\n').split(' ').collect(); + match fields.as_slice() { + [returned, returned_kind, size] if *returned == oid && *returned_kind == kind => size + .parse::() + .map_err(|_| object_error("batch object size is malformed")), + _ => Err(object_error(format!( + "object {oid} is not a readable {kind}" + ))), + } +} + +/// Computes a Git identity with SHA-1 collision detection. +/// +/// # Errors +/// +/// Returns an error when the collision detector rejects the supplied bytes. +pub(crate) fn checked_sha1(parts: &[&[u8]]) -> Result> { + let mut hasher = sha1_checked::Sha1::new(); + for part in parts { + sha1_checked::Digest::update(&mut hasher, part); + } + let result = hasher.try_finalize(); + if result.has_collision() { + return Err(object_error( + "SHA-1 collision detection flags a Git object; its identity is not trusted", + )); + } + Ok(hex::encode(result.hash())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn shared_reader_preserves_collision_checked_object_delivery() { + let bytes = b"fictional object"; + let mut hash = sha1_checked::Sha1::new(); + sha1_checked::Digest::update(&mut hash, format!("blob {}\0", bytes.len()).as_bytes()); + sha1_checked::Digest::update(&mut hash, bytes); + let oid = hex::encode(hash.try_finalize().hash()); + let mut delivered = false; + ObjectReader::source() + .read_objects( + "blob", + &[&oid], + &mut |arguments, _, _| { + let mut output = format!("{oid} blob {}\n", bytes.len()).into_bytes(); + if arguments.contains(&"--batch") { + output.extend_from_slice(bytes); + output.push(b'\n'); + } + Ok(output) + }, + |_, body| { + assert_eq!(body, bytes, "should deliver exact verified bytes"); + delivered = true; + Ok(()) + }, + ) + .expect("should verify a complete object through shared reader"); + assert!(delivered, "should deliver verified object"); + } + + #[test] + fn cargo_materials_reject_over_32_mib_before_requesting_body() { + let oid = "a".repeat(40); + for (reader, expects_body) in [ + (ObjectReader::cargo_materials(), false), + (ObjectReader::source(), true), + ] { + let body_requested = Cell::new(false); + let result = reader.read_objects( + "blob", + &[&oid], + &mut |arguments, _, _| { + if arguments.contains(&"--batch-check") { + Ok(format!("{oid} blob {}\n", 32 * 1024 * 1024 + 1).into_bytes()) + } else { + body_requested.set(true); + Err(Report::new(ReleaseError::Source) + .attach("fixture stops before allocating body")) + } + }, + |_, _| panic!("should never deliver fixture object"), + ); + assert!( + result.is_err(), + "should reject fixture without allocating large body" + ); + assert_eq!( + body_requested.get(), + expects_body, + "should preserve distinct source and Cargo per-object policies" + ); + } + } + + #[test] + fn cargo_collection_rejects_over_512_mib_before_any_body_request() { + let oid = "a".repeat(40); + let oids = vec![oid.as_str(); 17]; + for (reader, expects_body) in [ + (ObjectReader::cargo_materials(), false), + (ObjectReader::source(), true), + ] { + let mut body_requested = false; + let result = reader.read_objects( + "blob", + &oids, + &mut |arguments, _, _| { + if arguments.contains(&"--batch-check") { + Ok(format!("{oid} blob {}\n", 32 * 1024 * 1024) + .repeat(oids.len()) + .into_bytes()) + } else { + body_requested = true; + Err(object_error("fixture stops before body allocation")) + } + }, + |_, _| panic!("should never deliver large fixture object"), + ); + assert!( + result.is_err(), + "should reject fixture without materializing bodies" + ); + assert_eq!( + body_requested, expects_body, + "should preserve distinct aggregate source and Cargo limits" + ); + } + } + + #[test] + fn body_size_disagreement_rejects_before_budget_or_delivery() { + let bytes = b"fictional object"; + let oid = object_id("blob", bytes).expect("should hash fixture blob"); + let mut raw = format!("{oid} blob {}\n", bytes.len()).into_bytes(); + raw.extend_from_slice(bytes); + raw.push(b'\n'); + let budget = Cell::new(1024); + let batch = Batch { + kind: "blob", + object_limit: 64 * 1024 * 1024, + oids: &[&oid], + sizes: &[1], + budget: &budget, + }; + let mut delivered = false; + assert!( + read_batch(&mut Cursor::new(raw), &batch, &mut |_, _| { + delivered = true; + Ok(()) + }) + .is_err(), + "should reject body size inconsistent with preflight" + ); + assert!(!delivered, "should reject before delivering object bytes"); + assert_eq!( + budget.get(), + 1024, + "should preserve budget before metadata joins" + ); + } + + #[test] + fn cargo_budget_accumulates_across_separate_object_requests() { + let bytes = b"fictional object"; + let oid = object_id("blob", bytes).expect("should hash fixture object"); + let reader = ObjectReader::cargo_materials(); + reader.set_budget_for_tests(bytes.len() as u64); + let mut execute = |arguments: &[&str], _: Vec, _: u64| { + let mut output = format!("{oid} blob {}\n", bytes.len()).into_bytes(); + if arguments.contains(&"--batch") { + output.extend_from_slice(bytes); + output.push(b'\n'); + } + Ok(output) + }; + reader + .read_objects("blob", &[&oid], &mut execute, |_, _| Ok(())) + .expect("should consume entire shared budget"); + assert!( + reader + .read_objects("blob", &[&oid], &mut execute, |_, _| panic!( + "should not deliver after exhaustion" + )) + .is_err(), + "should preserve aggregate budget across requests" + ); + } +} diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs new file mode 100644 index 000000000..abdb21ee5 --- /dev/null +++ b/crates/trusted-server-release/src/main.rs @@ -0,0 +1,16 @@ +// These reviewed primitives have no release command until their callers land. +#[allow(dead_code, reason = "release command assembly is staged in Phase 2")] +mod documents; +mod error; +#[allow(dead_code, reason = "source closure caller is staged in Phase 2")] +mod git_objects; +#[allow(dead_code, reason = "confined execution callers are staged in Phase 2")] +mod process; + +use std::io::Write as _; +use std::process::ExitCode; + +fn main() -> ExitCode { + let _ = writeln!(std::io::stderr(), "{}", error::ReleaseError::Usage); + ExitCode::FAILURE +} diff --git a/crates/trusted-server-release/src/process.rs b/crates/trusted-server-release/src/process.rs new file mode 100644 index 000000000..f5c7bfb69 --- /dev/null +++ b/crates/trusted-server-release/src/process.rs @@ -0,0 +1,651 @@ +//! 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 `killpg` is repeated until it reports `ESRCH`. +//! +//! A residual pgid-reuse window remains after reaping: if every member is +//! already gone and the ID is recycled before the confirming `killpg`, that +//! call could reach the new group. Every original member has already been +//! sent `SIGKILL` while the ID was pinned, so this window affects only the +//! confirmation, not the kill. +//! +//! 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}; +use self::supervise::{KILL_GRACE, Watch, observe, reap_within, 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 }, + /// Signalling the process group failed with something other than `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, +} + +/// A file-mode creation mask a child runs under, restricted to permission bits. +/// +/// Rendered as three octal digits such as `022` or `027`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +#[display("{_0:03o}")] +pub(crate) struct Umask(u16); + +impl Umask { + /// The largest valid mask, `0o777`. + const MAX: u32 = 0o777; + + /// Accepts a numeric mask of at most `0o777`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] with [`ProcessFailure::InvalidCommand`] + /// for any value above `0o777`. + pub(crate) fn new(mask: u32) -> Result> { + match u16::try_from(mask) { + Ok(mask) if u32::from(mask) <= Self::MAX => Ok(Self(mask)), + _ => Err(invalid(format!("umask {mask:#o} is not within 0..=0o777"))), + } + } + + /// Returns the numeric mask. + #[must_use] + pub(crate) fn mask(self) -> u32 { + u32::from(self.0) + } +} + +/// 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, + umask: Option, + 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, + umask: 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 + } + + /// Sets the child's umask before it executes; the parent's is untouched. + #[must_use] + pub(crate) fn umask(mut self, umask: Umask) -> Self { + self.umask = Some(umask); + 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); + if let Some(umask) = self.umask { + let mode = libc::mode_t::from(umask.0); + // SAFETY: the closure runs in the forked child before exec and + // only calls `umask`, which is async-signal-safe, touches no + // memory and cannot fail; it captures one `Copy` integer. + unsafe { + command.pre_exec(move || { + libc::umask(mode); + Ok(()) + }); + } + } + 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> { + 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 000000000..4783ad4d2 --- /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 000000000..6f7c025ba --- /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 000000000..7d2df2e25 --- /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 000000000..c14b3fe07 --- /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 000000000..c356bfdca --- /dev/null +++ b/crates/trusted-server-release/src/process/supervise.rs @@ -0,0 +1,233 @@ +//! 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<()> { + // 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, libc::SIGKILL) }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +fn signal_failure(source: &io::Error) -> Report { + process_error( + ProcessFailure::Signal { + errno: source.raw_os_error(), + }, + format!("killpg failed: {source}"), + ) +} + +/// Returns whether `source` may be XNU's transient refusal to signal a group +/// that holds only zombies. +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, + }); + }; + kill_group_until_gone(process_group)?; + Ok(status) +} + +/// Sends `SIGKILL` to the group until `killpg` reports `ESRCH`. +/// +/// This runs after the leader is reaped, so the group ID is no longer pinned: +/// if every member is already gone and the ID is recycled before a call +/// here, that call could reach the new group. Every original member was +/// already sent `SIGKILL` by [`settle`] while the ID was pinned, so the window +/// affects only this confirmation. +fn kill_group_until_gone(process_group: libc::pid_t) -> Result<(), Report> { + let until = Instant::now() + KILL_GRACE; + loop { + let expired = Instant::now() >= until; + match signal_group(process_group) { + Err(source) if source.raw_os_error() == Some(libc::ESRCH) => return Ok(()), + Err(source) if expired || !is_transient_refusal(&source) => { + return Err(signal_failure(&source)); + } + Ok(()) if expired => { + return Err(process_error( + ProcessFailure::GroupSurvived, + format!("process group {process_group} survived SIGKILL"), + )); + } + Ok(()) | Err(_) => thread::sleep(POLL_INTERVAL), + } + } +} + +/// 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/tests.rs b/crates/trusted-server-release/src/process/tests.rs new file mode 100644 index 000000000..70fd1d5a4 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests.rs @@ -0,0 +1,56 @@ +//! 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, 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 000000000..55b00011d --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/fixture.rs @@ -0,0 +1,509 @@ +//! 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 = "TS_RELEASE_PROCESS_HELPER"; +/// Environment variable carrying the nonce the selector must match. +pub(super) const HELPER_NONCE: &str = "TS_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 = "TS_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)) + .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, + } +} + +/// 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 000000000..5a38ccba2 --- /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 000000000..287b516b6 --- /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 000000000..a6c665769 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/supervision.rs @@ -0,0 +1,298 @@ +//! 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" + ); +} 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 000000000..6b38da4b5 --- /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/docs/superpowers/plans/binary-attestations-phases-2-4.md b/docs/superpowers/plans/binary-attestations-phases-2-4.md index 1f71b33cb..3ccfcec6a 100644 --- a/docs/superpowers/plans/binary-attestations-phases-2-4.md +++ b/docs/superpowers/plans/binary-attestations-phases-2-4.md @@ -90,6 +90,8 @@ The local unpublished GNU masking commit `b91c7450a4f617d5ec879f612b295a532c7d7e ## 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. Merging Phase 2 activates the gate on the next real tag: one additional Fastly build plus comparison, and no release 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`). @@ -107,6 +109,18 @@ Verification selects each of the three new tests by its exact full name, require ## 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. Landing PRs for phases 2–4 are not created yet; their exact branch/base and intended titles are recorded above. | Frozen-source path | Landing phase | State | diff --git a/scripts/release/qualification/results.schema.json b/scripts/release/qualification/results.schema.json new file mode 100644 index 000000000..9ca1ef707 --- /dev/null +++ b/scripts/release/qualification/results.schema.json @@ -0,0 +1,554 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/release/qualification-results-v1.schema.json", + "title": "Disposable release environment observations (not qualification authority)", + "type": "object", + "additionalProperties": false, + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "purpose": { + "const": "disposable-rehearsal-observation" + }, + "source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "closure_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "selection_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "raw_tools_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "dependency_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "run": { + "type": "object", + "additionalProperties": false, + "properties": { + "repository_id": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "owner_id": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "run_id": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "attempt": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "job": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "runner": { + "const": "github-hosted" + } + }, + "required": [ + "repository_id", + "owner_id", + "run_id", + "attempt", + "job", + "runner" + ] + }, + "host": { + "type": "object", + "additionalProperties": false, + "properties": { + "environment_id": { + "enum": ["gnu-builder-x86_64", "macos15-aarch64", "macos15-x86_64"] + }, + "kind": { + "enum": ["oci-builder", "github-hosted-macos"] + }, + "architecture": { + "enum": ["aarch64", "x86_64"] + }, + "kernel_architecture": { + "enum": ["aarch64", "x86_64"] + }, + "os_version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "translated": { + "type": "boolean" + }, + "runner_image_version": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "oci_index_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "oci_manifest_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "glibc_version": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "identity_log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": [ + "environment_id", + "kind", + "architecture", + "kernel_architecture", + "os_version", + "translated", + "runner_image_version", + "oci_index_sha256", + "oci_manifest_sha256", + "glibc_version", + "identity_log_sha256" + ] + }, + "sdk": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["apple-sdk", "gnu-sysroot"] + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "build_version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "tree_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "vendor_evidence_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": [ + "kind", + "version", + "build_version", + "tree_sha256", + "vendor_evidence_sha256" + ] + }, + "tools": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "file_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "archive_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "tree_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "authentication": { + "type": "object", + "additionalProperties": false, + "properties": { + "method": { + "enum": [ + "vendor-signature", + "github-attestation", + "hosted-platform-sdk" + ] + }, + "evidence_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "trust_root_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": ["method", "evidence_sha256", "trust_root_sha256"] + } + }, + "required": [ + "name", + "version", + "file_sha256", + "archive_sha256", + "tree_sha256", + "authentication" + ] + }, + "maxItems": 32 + }, + "recipe": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "enum": [ + "ts-aarch64-apple-darwin", + "ts-x86_64-apple-darwin", + "ts-x86_64-unknown-linux-gnu", + "trusted-server-fastly.wasm", + "tsjs" + ] + }, + "boundary_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "preparation_log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "compile_log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "native_log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "exit_code": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "full_recipe": { + "type": "boolean" + }, + "same_boundary": { + "type": "boolean" + }, + "executable_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "native_materials_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "deployment_target": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "glibc_symbols": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "maxItems": 8192 + }, + "unconfined": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "depth": { + "type": "integer", + "minimum": 0, + "maximum": 2 + }, + "family": { + "enum": ["ipv4", "ipv6", "unix"] + }, + "operation": { + "enum": ["bind", "connect", "send", "exchange"] + }, + "destination": { + "enum": ["loopback", "external", "private"] + }, + "outcome": { + "enum": ["allowed", "denied", "unavailable"] + }, + "errno": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "phase": { + "enum": ["socket", "bind", "connect", "send", "exchange"] + }, + "protocol": { + "enum": ["tcp", "udp", "unix"] + } + }, + "required": [ + "depth", + "family", + "operation", + "destination", + "outcome", + "errno", + "phase", + "protocol" + ] + }, + "maxItems": 75 + }, + "confined": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "depth": { + "type": "integer", + "minimum": 0, + "maximum": 2 + }, + "family": { + "enum": ["ipv4", "ipv6", "unix"] + }, + "operation": { + "enum": ["bind", "connect", "send", "exchange"] + }, + "destination": { + "enum": ["loopback", "external", "private"] + }, + "outcome": { + "enum": ["allowed", "denied", "unavailable"] + }, + "errno": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "phase": { + "enum": ["socket", "bind", "connect", "send", "exchange"] + }, + "protocol": { + "enum": ["tcp", "udp", "unix"] + } + }, + "required": [ + "depth", + "family", + "operation", + "destination", + "outcome", + "errno", + "phase", + "protocol" + ] + }, + "maxItems": 75 + }, + "download_attempts": { + "type": "object", + "additionalProperties": false, + "properties": { + "cargo": { + "enum": ["denied", "allowed", "unavailable"] + }, + "npm": { + "enum": ["denied", "allowed", "unavailable"] + }, + "missing_tool": { + "enum": ["failed", "succeeded", "unavailable"] + } + }, + "required": ["cargo", "npm", "missing_tool"] + } + }, + "required": [ + "id", + "boundary_sha256", + "preparation_log_sha256", + "compile_log_sha256", + "native_log_sha256", + "exit_code", + "full_recipe", + "same_boundary", + "executable_sha256", + "native_materials_sha256", + "deployment_target", + "glibc_symbols", + "unconfined", + "confined", + "download_attempts" + ] + }, + "runtime": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "environment_id": { + "enum": [ + "ubi9-x86_64", + "ubuntu22.04-x86_64", + "debian12-x86_64", + "macos15-aarch64", + "macos15-x86_64", + "fastly-viceroy", + "js-pair" + ] + }, + "architecture": { + "enum": ["aarch64", "x86_64", "wasm32"] + }, + "os_version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "outside_checkout": { + "type": "boolean" + }, + "exit_code": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "kind": { + "enum": [ + "cli-startup", + "publisher-request", + "js-tests-and-equality" + ] + }, + "executable_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "executable_after_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "response_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "environment_id", + "architecture", + "os_version", + "outside_checkout", + "exit_code", + "kind", + "executable_sha256", + "executable_after_sha256", + "log_sha256", + "response_sha256" + ] + }, + "maxItems": 8 + } + }, + "required": [ + "schema_version", + "purpose", + "source_commit", + "closure_sha256", + "selection_sha256", + "raw_tools_sha256", + "dependency_sha256", + "run", + "host", + "sdk", + "tools", + "recipe", + "runtime" + ] +} From d0d5c37f3a51f55826f8a040063f1d46c7d95136 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 17:14:22 +0530 Subject: [PATCH 06/45] Specify reviewed ordinary Fastly gate implementation Record the approved merge activation, permanent companion profile contract, direct upload-action identity bridge, rerun rejection and read-only reusable-caller permissions before implementing the adapter. --- ...hase2-fastly-integration-implementation.md | 92 +++++++++++++++++++ ...-attestations-phase2-fastly-integration.md | 2 +- .../plans/binary-attestations-phases-2-4.md | 4 +- .../plans/binary-attestations-slice-map.json | 2 +- 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/binary-attestations-phase2-fastly-integration-implementation.md 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 000000000..55e27edb2 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration-implementation.md @@ -0,0 +1,92 @@ +# Ordinary Fastly release gate implementation plan + +**Status:** Gate 1 design approved on 2026-10-26. Independent plan/security review approved the plan after the concrete upload-output bridge, negative CI proof and reusable-caller permission correction below were recorded. Implementation may proceed within this scope. The frozen-source port continues independently in draft #1449. + +**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:** Merging Phase 2 enables this on the next real tag. There is no additional switch beyond the existing publication variable. Each release 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 + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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 + +- [ ] 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`. 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. +- [ ] 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`. +- [ ] 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 bounded pagination and rejecting truncation/duplicates. 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 partial rerun carrying old successful jobs/outputs fails closed; rerun all jobs rather than accepting old-attempt evidence. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 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. Native reproduction itself remains measured, never gated. PR sign/publish conditions remain false. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Implement in separate reviewed adapter commits, each citing approved Gate 1 and frozen primitives reused. Keep larger source-only port commits separate. +- [ ] 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. +- [ ] Independent and security review the authority boundary, API identities, archive/input joins, writer, workflow permissions/signing/publication and failure semantics before pushing. +- [ ] 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. +- [ ] 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. Add read-only CI canary jobs conditioned on that fixture outcome and an audit requiring both skipped; they have no signing/publication permissions or actions. Contract tests also 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. +- [ ] Rehearse 30 exact assets, immutable/prerelease/not-Latest, source and transfer joins, actual A/B equality and environments, real v1 receipt, both online/offline binary predicates unchanged, companion provenance verification, all release memberships, signed sums, download/readback, SPDX equality, native smoke and pinned Fastly pack/validate/inspect/compare/runtime without deployment. +- [ ] 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. +- [ ] Post full transcript/results, actual size counts and source citations on draft #1449. The user supplies the tracking issue number; do not create an issue. Keep draft and request no reviewers. diff --git a/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md index 357d79e08..2d4c6dea6 100644 --- a/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md +++ b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration.md @@ -64,7 +64,7 @@ Recipe hashing needs an explicit canonical encoding of source-owned build inputs 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 existing build matrix remains seven binaries. 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. +Inside `release-build.yml`, the fixed build cohort remains seven binaries. Canonical Fastly becomes a dedicated job so its upload-action outputs bind unambiguously to its artifacts; the other six 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. diff --git a/docs/superpowers/plans/binary-attestations-phases-2-4.md b/docs/superpowers/plans/binary-attestations-phases-2-4.md index 3ccfcec6a..06d75f035 100644 --- a/docs/superpowers/plans/binary-attestations-phases-2-4.md +++ b/docs/superpowers/plans/binary-attestations-phases-2-4.md @@ -99,7 +99,7 @@ Gate 1's [ordinary-release design](binary-attestations-phase2-fastly-integration - [ ] Adapt closed producer tables to the seven Phase 1 assets and current pins without opening qualifications or changing the index schema. - [ ] Run explicitly selected pure tests with a nonzero-selection guard; compile platform-specific branches in CI; keep process/ignored tests CI-only. - [ ] Obtain independent and security reviews; pass touched CI gates before pushing and rehearse only an exact green reviewed head. -- [ ] Request the separate Fastly integration design approval before implementing its missing gate/writer. Do not claim the intended Phase 2 capability is shipped by porting closed code. +- [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. ### Initial Phase 2 port @@ -121,7 +121,7 @@ The user approved exact `json-schema.org` and `spdx.org` reference-host entries ### 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. Landing PRs for phases 2–4 are not created yet; their exact branch/base and intended titles are recorded above. +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 draft #1449; Phase 3/4 PRs are not created yet. Exact branches/bases and intended titles are recorded above. | Frozen-source path | Landing phase | State | | -------------------------------------------------------------------------------------------------------------- | ------------- | --------------------------------------------------------- | diff --git a/docs/superpowers/plans/binary-attestations-slice-map.json b/docs/superpowers/plans/binary-attestations-slice-map.json index b5e3dc0b8..343503137 100644 --- a/docs/superpowers/plans/binary-attestations-slice-map.json +++ b/docs/superpowers/plans/binary-attestations-slice-map.json @@ -687,7 +687,7 @@ "2": { "branch": "feature/binary-attestations-phase2", "base": "feature/binary-attestations-phase1", - "pull_request": null + "pull_request": 1449 }, "3": { "branch": "feature/binary-attestations-phase3", From d1c1d6967c4a6813fe6d3b6c0168fb37a6618360 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 17:26:53 +0530 Subject: [PATCH 07/45] Align integration notes with immutable release facts Port the five remaining slice 2.3 comment/documentation paths from #1262 at 122b78664b1235c717e9208fd200f9a9b0c335df. Runtime configuration and release-source facts already landed in Phase 1; no executable behavior changes. --- .../browser/helpers/infra.ts | 4 ++-- .../tests/common/runtime.rs | 11 +++++------ .../tests/frameworks/mod.rs | 4 ++-- .../trusted-server-integration-tests/tests/parity.rs | 6 +++--- .../specs/2026-09-25-git-version-header-design.md | 2 ++ 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-integration-tests/browser/helpers/infra.ts b/crates/trusted-server-integration-tests/browser/helpers/infra.ts index 0402bb266..8e16560c9 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/infra.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/infra.ts @@ -32,8 +32,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 048c76d44..756e02131 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 e77fb9b91..14cff07b1 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 2428d7799..d3ab71424 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -25,9 +25,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/docs/superpowers/specs/2026-09-25-git-version-header-design.md b/docs/superpowers/specs/2026-09-25-git-version-header-design.md index eca379c01..421375259 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) 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 stays in the signed index; 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 From 021fdfd271fcc29e91f81855d8554473ea9fdff2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 17:32:55 +0530 Subject: [PATCH 08/45] Validate ordinary Fastly build observations Begin the separately approved Gate 1 adapter with strict observation shape, closed qualification claims and canonical/reproduction input/environment pairing. Keep the producer entrypoint closed while runtime pin, filesystem, transfer and equality stages remain pending. Verify red-green observation, root-alias and bounded-diagnostic regressions; 58 audited pure tests, strict producer clippy, formatting and domain lint pass. Independent review approves this staged unit with explicit runtime preconditions. --- crates/trusted-server-release/src/main.rs | 2 + crates/trusted-server-release/src/ordinary.rs | 231 ++++++++++++++++++ .../src/ordinary/tests.rs | 211 ++++++++++++++++ ...hase2-fastly-integration-implementation.md | 2 +- 4 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 crates/trusted-server-release/src/ordinary.rs create mode 100644 crates/trusted-server-release/src/ordinary/tests.rs diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index abdb21ee5..ac3aa4366 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -4,6 +4,8 @@ mod documents; mod error; #[allow(dead_code, reason = "source closure caller is staged in Phase 2")] mod git_objects; +#[allow(dead_code, reason = "ordinary adapter command assembly is pending")] +mod ordinary; #[allow(dead_code, reason = "confined execution callers are staged in Phase 2")] mod process; diff --git a/crates/trusted-server-release/src/ordinary.rs b/crates/trusted-server-release/src/ordinary.rs new file mode 100644 index 000000000..90aa69db3 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary.rs @@ -0,0 +1,231 @@ +//! Informational ordinary-release observations; these confer no qualification. +//! +//! Parsing and pairing establish neither source eligibility nor tool provenance. +//! Before comparison is enabled, the runtime recorder and transfer join must +//! enforce source-owned tool pins and actual filesystem identities; these +//! observation roots are only checked lexically here. + +use error_stack::Report; +use serde::Deserialize; +use serde_json::Value; +use std::path::{Component, Path}; + +use crate::documents::{self, Role}; +use crate::error::ReleaseError; + +const PROFILE: &str = "ordinary-release-fastly-byte-equality-v1"; +const ASSET: &str = "trusted-server-fastly.wasm"; +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 + } +} + +/// 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 = &canonical.environment; + let reproduction = &reproduction.environment; + require( + canonical.started_at_seconds != reproduction.started_at_seconds + && canonical.umask == 0o022 + && reproduction.umask == 0o027 + && canonical.jobs == 4 + && reproduction.jobs == 2 + && canonical.uid == reproduction.uid, + "ordinary environment pairing rejected", + )?; + for left in [&canonical.checkout_root, &canonical.target_root] { + for right in [&reproduction.checkout_root, &reproduction.target_root] { + require( + !Path::new(left).starts_with(right) && !Path::new(right).starts_with(left), + "ordinary build roots overlap", + )?; + } + } + 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, + tools: Tools, + environment: Environment, + artifact: Artifact, + gaps: Vec, +} + +#[derive(Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Tool { + version: String, + sha256: String, +} + +#[derive(Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Tools { + rustc: Tool, + node: Tool, + acquisition: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Environment { + checkout_root: String, + target_root: 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(condition: bool, message: &'static str) -> Result<(), Report> { + if !condition { + return Err(Report::new(ReleaseError::Document).attach(message)); + } + Ok(()) +} + +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.artifact.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( + facts.artifact.size > 0 && facts.artifact.size <= Role::Executable.limit(), + "ordinary artifact size rejected", + )?; + require(facts.gaps == GAPS, "ordinary assurance gaps rejected")?; + Ok(Observation { value, facts }) +} + +#[cfg(test)] +mod tests; 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 000000000..a6acccb18 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/tests.rs @@ -0,0 +1,211 @@ +use serde_json::{Value, json}; + +use super::{Observation, observation, pair}; + +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), + "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/canonical/source", + "target_root": "/example/canonical/target", + "started_at_seconds": 1700000000, + "umask": 18, + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "uid": 1001 + }, + "artifact": {"size": 8, "sha256": "2".repeat(64)}, + "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") +} + +fn reproduction() -> Value { + let mut value = fixture(); + value["role"] = json!("reproduction"); + value["environment"]["checkout_root"] = json!("/example/reproduction/source"); + value["environment"]["target_root"] = json!("/example/reproduction/target"); + 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_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}" + ); + } + 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/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"); + let diagnostic = format!("{error:?}"); + assert!( + diagnostic.len() < 2048 && !diagnostic.contains("unexpected_observation_key"), + "should keep diagnostics bounded without echoing untrusted field names" + ); +} diff --git a/docs/superpowers/plans/binary-attestations-phase2-fastly-integration-implementation.md b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration-implementation.md index 55e27edb2..60ed20a92 100644 --- a/docs/superpowers/plans/binary-attestations-phase2-fastly-integration-implementation.md +++ b/docs/superpowers/plans/binary-attestations-phase2-fastly-integration-implementation.md @@ -43,7 +43,7 @@ Every new ordinary module path is Gate 1 implementation, distinct from frozen-so ## Task 3 — Current-run transfer binding -- [ ] 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`. 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. +- [ ] 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. - [ ] 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`. - [ ] 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 bounded pagination and rejecting truncation/duplicates. 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 partial rerun carrying old successful jobs/outputs fails closed; rerun all jobs rather than accepting old-attempt evidence. - [ ] 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. From ad78fc5b20e45f29be0dfe8896f69bcc7e0da8f3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 17:55:22 +0530 Subject: [PATCH 09/45] Compare complete ordinary Fastly final bytes Extend the approved ordinary adapter with bounded full-byte equality after input pairing and observed digest/size checks. Retain bounded exact mismatch diagnostics without normalized or excluded regions; keep command assembly and qualified paths closed. Verify red-green equality coverage, custom-section and EOF differences, mutation-after-observation rejection, 62 audited pure tests and strict clippy. Independent review approves the staged comparison unit. --- crates/trusted-server-release/src/ordinary.rs | 76 +++++++++++++++++ .../src/ordinary/tests.rs | 83 ++++++++++++++++++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-release/src/ordinary.rs b/crates/trusted-server-release/src/ordinary.rs index 90aa69db3..a3474e7f7 100644 --- a/crates/trusted-server-release/src/ordinary.rs +++ b/crates/trusted-server-release/src/ordinary.rs @@ -8,6 +8,7 @@ use error_stack::Report; use serde::Deserialize; use serde_json::Value; +use sha2::{Digest as _, Sha256}; use std::path::{Component, Path}; use crate::documents::{self, Role}; @@ -35,6 +36,81 @@ impl Observation { } } +/// 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)?; + 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( + size > 0 && size <= Role::Executable.limit(), + "ordinary executable byte bound rejected", + )?; + let digest = hex::encode(Sha256::digest(bytes)); + require( + size == observed.facts.artifact.size && digest == observed.facts.artifact.sha256, + "ordinary executable changed after observation", + )?; + 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::Document).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), + ))); + } + 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, diff --git a/crates/trusted-server-release/src/ordinary/tests.rs b/crates/trusted-server-release/src/ordinary/tests.rs index a6acccb18..a82e3228b 100644 --- a/crates/trusted-server-release/src/ordinary/tests.rs +++ b/crates/trusted-server-release/src/ordinary/tests.rs @@ -1,6 +1,9 @@ use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; -use super::{Observation, observation, pair}; +use super::{Observation, compare, observation, pair}; + +const WASM: &[u8] = b"\0asm\x01\0\0\0"; fn fixture() -> Value { json!({ @@ -31,7 +34,7 @@ fn fixture() -> Value { "timezone": "UTC", "uid": 1001 }, - "artifact": {"size": 8, "sha256": "2".repeat(64)}, + "artifact": {"size": WASM.len(), "sha256": hex::encode(Sha256::digest(WASM))}, "gaps": [ "alternate-user-unqualified", "boundary-unqualified", @@ -63,6 +66,82 @@ fn accepts_matching_inputs_with_observed_environment_variation() { .expect("should accept matching ordinary inputs with measured variation"); } +#[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"); + let diagnostic = format!("{error:?}"); + 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 [ From bb05d98465ec9c00897142a6dc309e1d3f61dcaf Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 18:13:35 +0530 Subject: [PATCH 10/45] Validate bounded ordinary Fastly transfer archives Reuse the reviewed ZIP32 primitive and negative fixtures from #1262 at 122b78664b1235c717e9208fd200f9a9b0c335df without moving collection authority into Phase 2. Enforce exact build/observation member sets and source-owned bounds before the original full integrity checks. Verify red-green profile admission, 71 audited pure tests, strict clippy and an exact-ID read-only probe of the current Fastly archive against its recorded GitHub digest. Independent/security review approves this structural unit; transfer authentication remains pending. --- crates/trusted-server-release/src/ordinary.rs | 2 + .../src/ordinary/archive.rs | 380 ++++++++++++++++++ .../src/ordinary/archive/tests.rs | 370 +++++++++++++++++ 3 files changed, 752 insertions(+) create mode 100644 crates/trusted-server-release/src/ordinary/archive.rs create mode 100644 crates/trusted-server-release/src/ordinary/archive/tests.rs diff --git a/crates/trusted-server-release/src/ordinary.rs b/crates/trusted-server-release/src/ordinary.rs index a3474e7f7..e984d7156 100644 --- a/crates/trusted-server-release/src/ordinary.rs +++ b/crates/trusted-server-release/src/ordinary.rs @@ -14,6 +14,8 @@ use std::path::{Component, Path}; use crate::documents::{self, Role}; use crate::error::ReleaseError; +mod archive; + const PROFILE: &str = "ordinary-release-fastly-byte-equality-v1"; const ASSET: &str = "trusted-server-fastly.wasm"; const GAPS: [&str; 4] = [ 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 000000000..7d0b6f303 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/archive.rs @@ -0,0 +1,380 @@ +//! 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()), + (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)] +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 000000000..c71bd4ff3 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/archive/tests.rs @@ -0,0 +1,370 @@ +use std::collections::BTreeMap; +use std::io::Write as _; + +use flate2::Crc; + +use super::{Profile, contents}; + +pub(super) 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"{}"), + ], + 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(), + 2, + "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"{}"), + ("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 +} From ce7daa5f0c4d7d840574c94839adca51faa2c775 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 21:05:13 +0530 Subject: [PATCH 11/45] Bind ordinary uploads to current producing jobs --- Cargo.lock | 1 + crates/trusted-server-release/Cargo.toml | 1 + crates/trusted-server-release/src/ordinary.rs | 1 + .../src/ordinary/run.rs | 334 ++++++++++++++++++ .../src/ordinary/run/tests.rs | 294 +++++++++++++++ 5 files changed, 631 insertions(+) create mode 100644 crates/trusted-server-release/src/ordinary/run.rs create mode 100644 crates/trusted-server-release/src/ordinary/run/tests.rs diff --git a/Cargo.lock b/Cargo.lock index b621bf248..f17217e61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6997,6 +6997,7 @@ dependencies = [ "sha1-checked", "sha2 0.11.1", "tempfile", + "time", ] [[package]] diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml index 48bafce52..bf0a79cc8 100644 --- a/crates/trusted-server-release/Cargo.toml +++ b/crates/trusted-server-release/Cargo.toml @@ -18,6 +18,7 @@ serde_json.workspace = true sha1-checked = { version = "=0.10.0", default-features = false } sha2.workspace = true tempfile.workspace = true +time = { workspace = true, features = ["parsing"] } [lints] workspace = true diff --git a/crates/trusted-server-release/src/ordinary.rs b/crates/trusted-server-release/src/ordinary.rs index e984d7156..115bb122e 100644 --- a/crates/trusted-server-release/src/ordinary.rs +++ b/crates/trusted-server-release/src/ordinary.rs @@ -15,6 +15,7 @@ use crate::documents::{self, Role}; use crate::error::ReleaseError; mod archive; +mod run; const PROFILE: &str = "ordinary-release-fastly-byte-equality-v1"; const ASSET: &str = "trusted-server-fastly.wasm"; 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 000000000..9b4a5d3d9 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/run.rs @@ -0,0 +1,334 @@ +//! 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 current-attempt successful producing job and its exact two uploads. +#[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. + 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)] +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 000000000..e0be3a8cf --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/run/tests.rs @@ -0,0 +1,294 @@ +use serde_json::{Value, json}; + +use super::bind; + +fn encoded(value: &Value) -> Vec { + serde_json::to_vec(value).expect("should encode fixture") +} + +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) + } + }) +} + +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}) +} + +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() { + 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_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" + ); +} From 4447b68a5cc935d7ea7aaf9032d2b657537f1e98 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 21:06:26 +0530 Subject: [PATCH 12/45] Port reviewed tool tree content measurement --- crates/trusted-server-release/src/error.rs | 2 + crates/trusted-server-release/src/main.rs | 2 + .../trusted-server-release/src/tool_tree.rs | 109 ++++++++++++ .../src/tool_tree/tests.rs | 165 ++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 crates/trusted-server-release/src/tool_tree.rs create mode 100644 crates/trusted-server-release/src/tool_tree/tests.rs diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index 358f70a7f..10ad4b354 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -13,6 +13,8 @@ pub(crate) enum ReleaseError { Source, #[display("Git object verification failed")] GitObject, + #[display("Original release material is not verified")] + Material, #[display("Bounded release child process failed")] Process, } diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index ac3aa4366..44eac5a2a 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -8,6 +8,8 @@ mod git_objects; mod ordinary; #[allow(dead_code, reason = "confined execution callers are staged in Phase 2")] mod process; +#[allow(dead_code, reason = "tool tree recipe callers are staged in Phase 2")] +mod tool_tree; use std::io::Write as _; use std::process::ExitCode; diff --git a/crates/trusted-server-release/src/tool_tree.rs b/crates/trusted-server-release/src/tool_tree.rs new file mode 100644 index 000000000..0b9c1d591 --- /dev/null +++ b/crates/trusted-server-release/src/tool_tree.rs @@ -0,0 +1,109 @@ +//! Content digest of a selected tool tree, shared by the JS and Rust recipes. +//! +//! The digest covers every entry's relative path, kind, permission bits and, +//! for regular files, size and SHA-256. It rejects anything a later swap could +//! hide behind: hard-linked files (a second path can rewrite the content), +//! absolute or root-escaping symlinks, dangling symlinks, and special files. + +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::path::Path; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; + +/// Per-file byte limit while hashing a tool tree. +const FILE_BYTE_LIMIT: u64 = 512 * 1024 * 1024; +/// Total byte limit across one tool tree. +const TREE_BYTE_LIMIT: u64 = 2 * 1024 * 1024 * 1024; +/// Entry limit across one tool tree. +const ENTRY_LIMIT: usize = 100_000; + +fn tree_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Material).attach(detail.to_string()) +} + +/// Hashes the whole tree below `root`, calling `check` between entries and +/// file chunks so the owning attempt can cancel or expire it. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] for a hard-linked or special file, a +/// non-UTF-8 name, an absolute, dangling or root-escaping symlink, an +/// exceeded byte or entry limit, or when `check` fails. +pub(crate) fn tool_tree_digest( + root: &Path, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + let mut digest = Sha256::new(); + let mut total_bytes = 0_u64; + let mut pending = vec![root.to_owned()]; + let mut facts = BTreeMap::new(); + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory).map_err(tree_error)? { + check()?; + let entry = entry.map_err(tree_error)?; + let path = entry.path(); + let relative = path + .strip_prefix(root) + .map_err(tree_error)? + .to_str() + .ok_or_else(|| tree_error("tool path is not UTF-8"))? + .to_owned(); + let metadata = fs::symlink_metadata(&path).map_err(tree_error)?; + let fact = if metadata.is_dir() { + pending.push(path.clone()); + format!("directory:{}", metadata.permissions().mode() & 0o7777) + } else if metadata.is_file() && metadata.nlink() == 1 { + let raw = hash_bounded_with_check(&path, FILE_BYTE_LIMIT, check)?; + total_bytes += raw.size; + if total_bytes > TREE_BYTE_LIMIT { + return Err(tree_error("selected tool tree exceeds total byte limit")); + } + format!( + "file:{}:{}:{}", + metadata.permissions().mode() & 0o7777, + raw.size, + raw.sha256 + ) + } else if metadata.file_type().is_symlink() { + let target = fs::read_link(&path).map_err(tree_error)?; + if target.is_absolute() + || !fs::canonicalize(&path) + .map_err(tree_error)? + .starts_with(root) + { + return Err(tree_error("selected tool alias escapes its owned root")); + } + format!( + "alias:{}", + target + .to_str() + .ok_or_else(|| tree_error("tool alias is not UTF-8"))? + ) + } else { + return Err(tree_error( + "selected tool contains an unsafe file type or hardlink", + )); + }; + facts.insert(relative, fact); + if facts.len() > ENTRY_LIMIT { + return Err(tree_error("selected tool tree exceeds file limit")); + } + } + } + for (relative, fact) in facts { + digest.update(relative.as_bytes()); + digest.update([0]); + digest.update(fact.as_bytes()); + digest.update([0]); + } + Ok(hex::encode(digest.finalize())) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/tool_tree/tests.rs b/crates/trusted-server-release/src/tool_tree/tests.rs new file mode 100644 index 000000000..f88887c84 --- /dev/null +++ b/crates/trusted-server-release/src/tool_tree/tests.rs @@ -0,0 +1,165 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; + +use super::*; + +/// A fictional tool tree below a canonical temporary root. +struct Tree { + _directory: tempfile::TempDir, + root: PathBuf, +} + +impl Tree { + fn new() -> Self { + let directory = tempfile::tempdir().expect("should create tree directory"); + let outer = fs::canonicalize(directory.path()).expect("should canonicalize"); + let root = outer.join("tools"); + fs::create_dir_all(root.join("bin")).expect("should create bin"); + fs::write(root.join("bin/tool"), b"fictional tool").expect("should write tool"); + fs::create_dir(outer.join("outside")).expect("should create outside directory"); + fs::write(outer.join("outside/secret"), b"outside").expect("should write outside file"); + Self { + _directory: directory, + root, + } + } + + fn digest(&self) -> Result> { + tool_tree_digest(&self.root, &|| Ok(())) + } + + fn rejection(&self) -> String { + match self.digest() { + Ok(_) => panic!("should reject the tree"), + Err(error) => format!("{error:?}"), + } + } +} + +#[test] +fn identical_trees_have_identical_digests_and_edits_change_them() { + let tree = Tree::new(); + let baseline = tree.digest().expect("should digest the tree"); + assert_eq!( + baseline, + tree.digest().expect("should digest again"), + "should be deterministic over an unchanged tree" + ); + assert_eq!(baseline.len(), 64, "should be a SHA-256 hex digest"); + + fs::write(tree.root.join("bin/tool"), b"fictional tool!").expect("should edit content"); + let edited = tree.digest().expect("should digest the edited tree"); + assert_ne!(baseline, edited, "should detect a content edit"); + + fs::write(tree.root.join("bin/tool"), b"fictional tool").expect("should restore content"); + assert_eq!( + baseline, + tree.digest().expect("should digest the restored tree"), + "should return to the baseline when content is restored" + ); + + fs::set_permissions( + tree.root.join("bin/tool"), + fs::Permissions::from_mode(0o755), + ) + .expect("should change mode"); + assert_ne!( + baseline, + tree.digest().expect("should digest the chmod tree"), + "should detect a permission change" + ); +} + +#[test] +fn added_and_renamed_entries_change_the_digest() { + let tree = Tree::new(); + let baseline = tree.digest().expect("should digest the tree"); + fs::rename(tree.root.join("bin/tool"), tree.root.join("bin/renamed")) + .expect("should rename the tool"); + assert_ne!( + baseline, + tree.digest().expect("should digest the renamed tree"), + "should detect a rename" + ); + fs::rename(tree.root.join("bin/renamed"), tree.root.join("bin/tool")) + .expect("should restore the name"); + fs::write(tree.root.join("extra"), b"").expect("should add an entry"); + assert_ne!( + baseline, + tree.digest().expect("should digest the extended tree"), + "should detect an added empty file" + ); +} + +#[test] +fn hard_linked_files_are_rejected() { + let tree = Tree::new(); + fs::hard_link(tree.root.join("bin/tool"), tree.root.join("bin/alias")) + .expect("should link inside the tree"); + assert!( + tree.rejection().contains("hardlink"), + "should reject a second link inside the tree" + ); + let tree = Tree::new(); + let outside = tree + .root + .parent() + .expect("should have parent") + .join("outside/secret"); + fs::hard_link(&outside, tree.root.join("bin/leak")).expect("should link from outside"); + assert!( + tree.rejection().contains("hardlink"), + "should reject a link shared with a path outside the tree" + ); +} + +#[test] +fn escaping_dangling_or_absolute_aliases_are_rejected() { + let cases: [(&str, &str); 3] = [ + ("relative escape", "../../outside/secret"), + ("dangling", "missing"), + ("absolute", "/usr/bin/true"), + ]; + for (label, target) in cases { + let tree = Tree::new(); + symlink(target, tree.root.join("bin/alias")).expect("should create alias"); + let rejection = tree.rejection(); + assert!( + rejection.contains("alias") || rejection.contains("No such file"), + "should reject the {label} alias: {rejection}" + ); + } +} + +#[test] +fn inside_relative_alias_is_accepted_and_recorded() { + let tree = Tree::new(); + let baseline = tree.digest().expect("should digest the tree"); + symlink("tool", tree.root.join("bin/alias")).expect("should create inside alias"); + let with_alias = tree + .digest() + .expect("should accept an inside relative alias"); + assert_ne!( + baseline, with_alias, + "should record the alias in the digest" + ); + fs::remove_file(tree.root.join("bin/alias")).expect("should remove alias"); + symlink("./tool", tree.root.join("bin/alias")).expect("should create alias with other text"); + assert_ne!( + with_alias, + tree.digest().expect("should digest the other alias"), + "should bind the alias target text" + ); +} + +#[test] +fn failing_check_stops_the_digest() { + let tree = Tree::new(); + let error = tool_tree_digest(&tree.root, &|| Err(tree_error("cancelled"))) + .expect_err("should stop when the owning attempt fails"); + assert!( + format!("{error:?}").contains("cancelled"), + "should surface the check failure" + ); +} From ee5598bf59d59ac2601f064397762738c69da63d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 21:11:28 +0530 Subject: [PATCH 13/45] Port reviewed final executable inspection Carry the frozen inspection implementation from 122b78664. Render the future recipe reference as inline code until its module lands. Keep release commands closed. --- Cargo.lock | 11 + crates/trusted-server-release/Cargo.toml | 12 + crates/trusted-server-release/src/error.rs | 2 + crates/trusted-server-release/src/inspect.rs | 708 ++++++++++++++++ .../trusted-server-release/src/inspect/elf.rs | 487 +++++++++++ .../src/inspect/elf/loader.rs | 301 +++++++ .../src/inspect/macho.rs | 438 ++++++++++ .../src/inspect/macho/signature.rs | 164 ++++ .../src/inspect/tests.rs | 144 ++++ .../src/inspect/tests/builders.rs | 764 ++++++++++++++++++ .../src/inspect/tests/builders/macho_data.rs | 72 ++ .../src/inspect/tests/elf.rs | 429 ++++++++++ .../src/inspect/tests/macho.rs | 480 +++++++++++ .../src/inspect/tests/wasm.rs | 332 ++++++++ .../src/inspect/wasm.rs | 159 ++++ crates/trusted-server-release/src/main.rs | 2 + 16 files changed, 4505 insertions(+) create mode 100644 crates/trusted-server-release/src/inspect.rs create mode 100644 crates/trusted-server-release/src/inspect/elf.rs create mode 100644 crates/trusted-server-release/src/inspect/elf/loader.rs create mode 100644 crates/trusted-server-release/src/inspect/macho.rs create mode 100644 crates/trusted-server-release/src/inspect/macho/signature.rs create mode 100644 crates/trusted-server-release/src/inspect/tests.rs create mode 100644 crates/trusted-server-release/src/inspect/tests/builders.rs create mode 100644 crates/trusted-server-release/src/inspect/tests/builders/macho_data.rs create mode 100644 crates/trusted-server-release/src/inspect/tests/elf.rs create mode 100644 crates/trusted-server-release/src/inspect/tests/macho.rs create mode 100644 crates/trusted-server-release/src/inspect/tests/wasm.rs create mode 100644 crates/trusted-server-release/src/inspect/wasm.rs diff --git a/Cargo.lock b/Cargo.lock index f17217e61..41e3707f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4410,6 +4410,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -6992,12 +7001,14 @@ dependencies = [ "hex", "jsonschema", "libc", + "object", "serde", "serde_json", "sha1-checked", "sha2 0.11.1", "tempfile", "time", + "wasmparser", ] [[package]] diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml index bf0a79cc8..113d70277 100644 --- a/crates/trusted-server-release/Cargo.toml +++ b/crates/trusted-server-release/Cargo.toml @@ -13,12 +13,24 @@ flate2.workspace = true hex.workspace = true libc.workspace = true jsonschema = { version = "=0.58.6", default-features = false } +object = { version = "=0.37.3", default-features = false, features = [ + "elf", + "macho", + "read_core", + "std", +] } serde.workspace = true serde_json.workspace = true sha1-checked = { version = "=0.10.0", default-features = false } sha2.workspace = true tempfile.workspace = true time = { workspace = true, features = ["parsing"] } +wasmparser = { version = "=0.247.0", default-features = false, features = [ + "features", + "simd", + "std", + "validate", +] } [lints] workspace = true diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index 10ad4b354..e207a511b 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -13,6 +13,8 @@ pub(crate) enum ReleaseError { Source, #[display("Git object verification failed")] GitObject, + #[display("Final artifact inspection rejected the executable")] + Inspect, #[display("Original release material is not verified")] Material, #[display("Bounded release child process failed")] diff --git a/crates/trusted-server-release/src/inspect.rs b/crates/trusted-server-release/src/inspect.rs new file mode 100644 index 000000000..aa0cf1ce2 --- /dev/null +++ b/crates/trusted-server-release/src/inspect.rs @@ -0,0 +1,708 @@ +//! Pure inspection of final release executables. +//! +//! Each inspector reads only the final bytes of one artifact: it never runs +//! the artifact, rebuilds it or consults the build environment. A successful +//! inspection returns facts that record what was observed — loader, libraries, +//! version floors, Wasm import modules and the SHA-256 of the inspected bytes — +//! for later build evidence. Facts make no claim beyond that observation; +//! startup and runtime behaviour belong to the recipe and qualification +//! fixtures. +//! +//! Every inspector fails closed: an unsupported container, a truncated or +//! malformed structure, or any value outside the compiled-in policy rejects +//! the artifact with [`ReleaseError::Inspect`] and an attached [`Violation`] +//! naming the rule. +//! +//! - [`inspect_elf`]: `x86_64-unknown-linux-gnu` executables against the +//! glibc 2.34 loader floor. +//! - [`inspect_macho`]: thin 64-bit macOS executables against the macOS 13.0 +//! deployment target. +//! - [`inspect_wasm`]: Fastly Compute core modules. + +mod elf; +mod macho; +mod wasm; + +use std::fmt; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; + +// Only the tests consume the inspectors until the Task 9 recipes call them. +#[allow(unused_imports, reason = "consumed by Task 9 recipes")] +pub(crate) use self::{ + elf::{ElfPolicy, inspect_elf}, + macho::{CodeSignature, MachoCpu, MachoPolicy, inspect_macho}, + wasm::inspect_wasm, +}; + +/// Default build-host or runner path prefixes, retained by macOS inspection. +/// +/// A final executable embedding any of them leaks the build environment +/// (unremapped source, home, temporary or workspace paths). The scan is +/// deliberately conservative: it matches anywhere in the file, not only in +/// loader strings. GNU ELF inspection uses its own reviewed list; this default +/// retains generic temporary paths for macOS. +/// +/// Matches are substrings, not path starts: Rust panic locations sit in +/// unterminated rodata, so the byte before a path is arbitrary. Entries are +/// therefore specific enough not to match ordinary crate-relative paths; a +/// bare `/private/` would match serde's `src/private/de.rs`. +pub(crate) const RUNNER_PATH_PREFIXES: &[&str] = &[ + "/home/", + "/Users/", + "/root/", + "/private/var/", + "/private/tmp/", + "/var/folders/", + "/tmp/", + "/github/workspace/", + "/__w/", +]; + +/// Maximum number of embedded-path offsets reported in a rejection. +const REPORTED_PATH_HITS: usize = 16; +/// Source bytes before each denied-path match in diagnostic context. +const PATH_CONTEXT_BEFORE: usize = 16; +/// Source bytes from each denied-path match onward in diagnostic context. +const PATH_CONTEXT_AFTER: usize = 80; +/// Maximum section-name bytes included in a diagnostic. +const SECTION_NAME_BYTES: usize = 32; + +/// The inspection rule an artifact violated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum Violation { + /// The bytes are truncated, malformed or not the expected container. + #[display("malformed or unsupported executable container")] + Malformed, + /// The container is valid but the class, CPU or file type is not the + /// selected target's. + #[display("executable architecture or file type does not match the target")] + Architecture, + /// The program interpreter or dynamic linker is missing or unexpected. + #[display("program interpreter is missing or unexpected")] + Interpreter, + /// A linked library is outside the reviewed allowlist. + #[display("linked library is outside the reviewed allowlist")] + Library, + /// A library link uses a kind other than a plain required load. + #[display("library link kind is not a plain required load")] + LinkKind, + /// A symbol version requirement is unparsable, unexpected or above the + /// compatibility ceiling. + #[display("symbol version requirement is unparsable or exceeds the ceiling")] + SymbolVersion, + /// The executable carries a runtime library search path or loader + /// environment. + #[display("executable carries a runtime library search path")] + SearchPath, + /// The executable embeds a runner-only absolute path. + #[display("executable embeds a runner-only absolute path")] + EmbeddedPath, + /// The deployment target is missing, ambiguous or not the selected floor. + #[display("deployment target is missing or does not match the floor")] + DeploymentTarget, + /// The bytes are a Wasm component or not Wasm at all. + #[display("artifact is not a core WebAssembly module")] + WasmEncoding, + /// The core module fails validation under the pinned feature set. + #[display("core WebAssembly module fails validation")] + WasmValidation, + /// The module imports from an unreviewed module or a non-function item. + #[display("WebAssembly import is outside the reviewed host ABI")] + WasmImport, + /// A required DWARF custom section is missing or empty, so the module + /// was stripped. + #[display("WebAssembly module lacks a required nonempty debug section")] + WasmDebugInfo, + /// A required DWARF section of an ELF executable is missing or holds no + /// file bytes, so the executable was stripped. + #[display("ELF executable lacks a required nonempty debug section")] + ElfDebugInfo, + /// The module is not a Fastly Compute WASI command: `_start` or `memory` + /// is missing or mistyped, or the downstream request ABI is not imported. + #[display("WebAssembly module lacks the Fastly Compute entry ABI")] + WasmAbi, + /// The Mach-O code signature is malformed, not a linker-signed ad-hoc + /// signature, carries a CMS signature or team identifier, or is missing + /// where the CPU requires it. + #[display("Mach-O code signature is not the reviewed linker-signed ad-hoc form")] + CodeSignature, + /// The Mach-O debug map names an absolute or empty object path, records + /// a nonzero object modification time, or is missing where required. + #[display("Mach-O debug map is missing, absolute or time-stamped")] + MachoDebugMap, + /// The Mach-O `LC_UUID` is missing or duplicated. + #[display("Mach-O executable identity is missing or duplicated")] + MachoIdentity, +} + +/// Builds the inspection rejection carrying `violation` and a detail line. +fn rejected(violation: Violation, detail: impl fmt::Display) -> Report { + Report::new(ReleaseError::Inspect) + .attach(violation) + .attach(detail.to_string()) +} + +/// Maps a parser error onto [`Violation::Malformed`]. +fn malformed(context: &str, error: impl fmt::Display) -> Report { + rejected(Violation::Malformed, format!("{context}: {error}")) +} + +/// Returns the lowercase hex SHA-256 of `bytes`. +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Decodes a loader string, rejecting non-UTF-8 bytes. +fn utf8<'a>(bytes: &'a [u8], context: &str) -> Result<&'a str, Report> { + std::str::from_utf8(bytes).map_err(|error| malformed(context, error)) +} + +/// One occurrence of a denied path prefix in the inspected bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PathHit { + prefix: &'static str, + offset: usize, +} + +/// Finds every occurrence of any `prefixes` entry anywhere in `bytes`. +/// +/// # Performance +/// +/// Candidates are found by scanning for `/` only, so the cost is one pass +/// plus a short comparison per slash rather than one window per prefix. +fn find_denied_paths<'a>( + bytes: &'a [u8], + prefixes: &'a [&'static str], +) -> impl Iterator + 'a { + bytes + .iter() + .enumerate() + .filter(|(_, byte)| **byte == b'/') + .filter_map(move |(offset, _)| { + prefixes + .iter() + .find(|prefix| bytes[offset..].starts_with(prefix.as_bytes())) + .map(|prefix| PathHit { prefix, offset }) + }) +} + +/// Rejects `bytes` if they embed any denied path prefix. +/// +/// # Errors +/// +/// Returns [`Violation::EmbeddedPath`] listing the match count and the first +/// [`REPORTED_PATH_HITS`] prefix/offset pairs with bounded escaped context and +/// the exact artifact identity. +fn reject_denied_paths( + bytes: &[u8], + prefixes: &[&'static str], +) -> Result<(), Report> { + reject_denied_paths_with_sections(bytes, prefixes, |_| None) +} + +/// Rejects the same denied paths, adding bounded section and byte diagnostics. +fn reject_denied_paths_with_sections<'a>( + bytes: &[u8], + prefixes: &[&'static str], + section_at: impl Fn(usize) -> Option<&'a [u8]>, +) -> Result<(), Report> { + let mut count = 0_usize; + let mut shown = Vec::new(); + for hit in find_denied_paths(bytes, prefixes) { + count += 1; + if shown.len() == REPORTED_PATH_HITS { + continue; + } + let start = hit.offset.saturating_sub(PATH_CONTEXT_BEFORE); + let end = hit + .offset + .saturating_add(PATH_CONTEXT_AFTER) + .min(bytes.len()); + let context = bytes[start..end].escape_ascii(); + let section = section_at(hit.offset).map_or_else( + || "unknown".to_owned(), + |name| { + name[..name.len().min(SECTION_NAME_BYTES)] + .escape_ascii() + .to_string() + }, + ); + shown.push(format!( + "{}@{:#x} section=\"{section}\" context[{start:#x}..{end:#x}]=\"{context}\"", + hit.prefix, hit.offset, + )); + } + if count == 0 { + return Ok(()); + } + Err(rejected( + Violation::EmbeddedPath, + format!( + "artifact sha256={} size={}; {count} runner-only path occurrence(s): {}", + sha256_hex(bytes), + bytes.len(), + shown.join(", "), + ), + )) +} + +/// A numeric `GLIBC_x.y[.z]` symbol version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct GlibcVersion { + major: u32, + minor: u32, + patch: u32, +} + +impl GlibcVersion { + /// Creates `major.minor`. + pub(crate) const fn new(major: u32, minor: u32) -> Self { + Self { + major, + minor, + patch: 0, + } + } + + /// Parses `x.y` or `x.y.z` made of plain decimal components. + /// + /// Signs, empty components, leading zeros and more than three + /// components are rejected, so names such as `GLIBC_PRIVATE` or + /// `GLIBC_ABI_DT_RELR` never compare as versions. + pub(crate) fn parse(text: &str) -> Option { + let parts = text.split('.').map(decimal).collect::>>()?; + match parts.as_slice() { + [major, minor] => Some(Self::new(*major, *minor)), + [major, minor, patch] => Some(Self { + major: *major, + minor: *minor, + patch: *patch, + }), + _ => None, + } + } +} + +impl fmt::Display for GlibcVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}.{}", self.major, self.minor)?; + if self.patch != 0 { + write!(formatter, ".{}", self.patch)?; + } + Ok(()) + } +} + +/// Parses one canonical unsigned decimal component. +fn decimal(text: &str) -> Option { + let canonical = !text.is_empty() + && text.bytes().all(|byte| byte.is_ascii_digit()) + && (text == "0" || !text.starts_with('0')); + if canonical { text.parse().ok() } else { None } +} + +/// One `(library, version name)` pair from `.gnu.version_r`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +pub(crate) struct VersionRequirement { + file: String, + name: String, +} + +/// One section of an ELF executable named like a required DWARF section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ElfDebugSection { + name: String, + size: usize, +} + +impl ElfDebugSection { + /// Section name, one of the required DWARF section names. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// File bytes the section holds; zero for `SHT_NOBITS`. + pub(crate) fn size(&self) -> usize { + self.size + } +} + +/// Facts observed in an accepted ELF executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ElfFacts { + sha256: String, + interpreter: String, + needed: Vec, + version_requirements: Vec, + max_glibc: GlibcVersion, + debug_sections: Vec, +} + +impl ElfFacts { + /// Sections named like a required DWARF section, in file order. + pub(crate) fn debug_sections(&self) -> &[ElfDebugSection] { + &self.debug_sections + } + + /// Requires nonempty `.debug_info` and `.debug_line` sections. + /// + /// Release executables keep limited debug info (`debug = 1`) and are + /// never stripped, so a missing or empty section means the bytes are not + /// the profile's output. + /// + /// # Errors + /// + /// Returns [`Violation::ElfDebugInfo`] naming the first required section + /// that is absent or holds no file bytes in every occurrence. + pub(crate) fn require_debug_sections(&self) -> Result<(), Report> { + for required in REQUIRED_DEBUG_SECTIONS { + let present = self + .debug_sections + .iter() + .any(|section| section.name == required && section.size > 0); + if !present { + return Err(rejected( + Violation::ElfDebugInfo, + format!("missing or empty section {required}"), + )); + } + } + Ok(()) + } + + /// Lowercase hex SHA-256 of the inspected bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Observed `PT_INTERP` path. + pub(crate) fn interpreter(&self) -> &str { + &self.interpreter + } + + /// Observed `DT_NEEDED` entries in file order. + pub(crate) fn needed(&self) -> &[String] { + &self.needed + } + + /// Observed `.gnu.version_r` requirements, sorted. + pub(crate) fn version_requirements(&self) -> &[VersionRequirement] { + &self.version_requirements + } + + /// Highest observed `GLIBC_` requirement. + pub(crate) fn max_glibc(&self) -> GlibcVersion { + self.max_glibc + } +} + +/// A macOS `X.Y.Z` version as encoded in `LC_BUILD_VERSION`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, derive_more::Display)] +#[display("{major}.{minor}.{patch}")] +pub(crate) struct MacosVersion { + major: u16, + minor: u8, + patch: u8, +} + +impl MacosVersion { + /// Creates `major.minor.0`. + pub(crate) const fn new(major: u16, minor: u8) -> Self { + Self { + major, + minor, + patch: 0, + } + } + + /// Parses `major.minor` or `major.minor.patch` decimal text, as an SDK + /// settings file states its version. + /// + /// Returns `None` for any other shape, a component with a leading zero or + /// sign, or a component out of range. + pub(crate) fn parse(text: &str) -> Option { + fn component(part: &str) -> Option { + let canonical = !part.is_empty() + && part.bytes().all(|byte| byte.is_ascii_digit()) + && (part == "0" || !part.starts_with('0')); + canonical.then(|| part.parse().ok()).flatten() + } + let mut parts = text.split('.'); + let major = component(parts.next()?)?; + let minor = component(parts.next()?)?; + let patch = parts.next().map_or(Some(0), component)?; + parts.next().is_none().then_some(Self { + major, + minor, + patch, + }) + } + + /// Decodes the `xxxx.yy.zz` nibble encoding. + fn from_encoded(encoded: u32) -> Self { + let [high, low, minor, patch] = encoded.to_be_bytes(); + Self { + major: u16::from_be_bytes([high, low]), + minor, + patch, + } + } +} + +/// Facts observed in an accepted Mach-O executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MachoFacts { + sha256: String, + loader: String, + cpu: MachoCpu, + minos: MacosVersion, + sdk: MacosVersion, + dylibs: Vec, + uuid: [u8; 16], + symbols: usize, + debug_map_objects: usize, + signature: CodeSignature, +} + +impl MachoFacts { + /// Observed and validated `LC_LOAD_DYLINKER` install name. + pub(crate) fn loader(&self) -> &str { + &self.loader + } + + /// Observed `LC_UUID`; a linked executable always carries exactly one. + pub(crate) fn uuid(&self) -> [u8; 16] { + self.uuid + } + + /// Number of `LC_SYMTAB` entries, including debug-map stabs. + pub(crate) fn symbol_count(&self) -> usize { + self.symbols + } + + /// Number of `N_OSO` debug-map object entries, all relative with zero + /// modification time. + pub(crate) fn debug_map_objects(&self) -> usize { + self.debug_map_objects + } + + /// Observed code signature form. + pub(crate) fn signature(&self) -> CodeSignature { + self.signature + } + + /// Requires the debug map a release build keeps: a non-empty symbol + /// table holding at least one `N_OSO` object entry. + /// + /// # Errors + /// + /// Returns [`Violation::MachoDebugMap`] when the symbol table is empty + /// or holds no object entry, so the executable was stripped. + pub(crate) fn require_debug_map(&self) -> Result<(), Report> { + if self.symbols == 0 || self.debug_map_objects == 0 { + return Err(rejected( + Violation::MachoDebugMap, + format!( + "{} symbols and {} debug-map objects; the executable was stripped", + self.symbols, self.debug_map_objects + ), + )); + } + Ok(()) + } + /// Lowercase hex SHA-256 of the inspected bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Observed CPU type. + pub(crate) fn cpu(&self) -> MachoCpu { + self.cpu + } + + /// Observed `LC_BUILD_VERSION` minimum OS version. + pub(crate) fn minos(&self) -> MacosVersion { + self.minos + } + + /// Observed `LC_BUILD_VERSION` SDK version. + pub(crate) fn sdk(&self) -> MacosVersion { + self.sdk + } + + /// Observed `LC_LOAD_DYLIB` install names in load-command order. + pub(crate) fn dylibs(&self) -> &[String] { + &self.dylibs + } +} + +/// One custom section observed in a core Wasm module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WasmCustomSection { + name: String, + size: usize, +} + +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, +} + +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. +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. +const REQUIRED_FASTLY_IMPORT_MODULES: [&str; 2] = ["fastly_http_req", "fastly_http_resp"]; + +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::WasmAbi`] 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::WasmAbi, + format!("export _start must be a nullary function, found {other:?}"), + )); + } + } + if kind("memory") != Some(WasmExportKind::Memory) { + return Err(rejected( + Violation::WasmAbi, + "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::WasmAbi, + 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::WasmDebugInfo`] 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::WasmDebugInfo, + 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/elf.rs b/crates/trusted-server-release/src/inspect/elf.rs new file mode 100644 index 000000000..13c6cec13 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/elf.rs @@ -0,0 +1,487 @@ +//! ELF inspection for `x86_64-unknown-linux-gnu` executables. +//! +//! Section observations are accepted only after matching the loader's +//! dynamic addresses to their actual file-backed `PT_LOAD` ranges. + +mod loader; + +use std::collections::BTreeSet; + +use error_stack::Report; +use object::Endianness; +use object::elf::{self as format, FileHeader64}; +use object::read::elf::{Dyn as _, FileHeader as _, ProgramHeader as _, SectionHeader as _}; +use object::read::{SectionIndex, StringTable}; + +use super::{ + ElfDebugSection, ElfFacts, GlibcVersion, REQUIRED_DEBUG_SECTIONS, VersionRequirement, + Violation, malformed, reject_denied_paths_with_sections, rejected, sha256_hex, utf8, +}; +use crate::error::ReleaseError; + +type Header = FileHeader64; +type Sections<'data> = object::read::elf::SectionTable<'data, Header, &'data [u8]>; + +/// Version-name prefix whose suffix is compared to the glibc ceiling. +const GLIBC_PREFIX: &str = "GLIBC_"; + +/// Reviewed `x86_64-unknown-linux-gnu` loader. +const LINUX_X86_64_INTERPRETER: &str = "/lib64/ld-linux-x86-64.so.2"; + +/// Reviewed `DT_NEEDED` set for the `ts` CLI. +/// +/// Rust std on `linux-gnu` links libc, libm, `libgcc_s` (unwinding) and the +/// loader (`__tls_get_addr`). From glibc 2.34 `libdl`, `libpthread` and +/// `librt` are merged into libc, so they are deliberately absent: a builder +/// that still emits them is not the qualified floor. +const LINUX_X86_64_NEEDED: &[&str] = &[ + "ld-linux-x86-64.so.2", + "libc.so.6", + "libgcc_s.so.1", + "libm.so.6", +]; + +/// GNU-specific runner path prefixes, excluding the generic runtime `/tmp/`. +/// +/// The Linux CLI includes a dependency's browser lookup under `/tmp/`; that +/// runtime string is not a build path. Complete GNU recipe acceptance also +/// requires read-only unbound scratch, IPC without writable shared memory, +/// no image-declared volumes, and the bound host/execution-root scan in +/// `crate::recipe::PreparedRecipe`. This list alone cannot establish build +/// path provenance. The default macOS list retains `/tmp/` until its own +/// filesystem boundary is qualified. +const LINUX_X86_64_DENIED_PATH_PREFIXES: &[&str] = &[ + "/home/", + "/Users/", + "/root/", + "/private/var/", + "/private/tmp/", + "/var/folders/", + "/github/workspace/", + "/__w/", +]; + +/// Version-name prefixes accepted per library, besides `GLIBC_`. +/// +/// `libgcc_s` versions its unwinder as `GCC_x.y[.z]`; those carry no glibc +/// floor and are recorded only. +const OTHER_VERSION_PREFIXES: &[(&str, &str)] = &[("libgcc_s.so.1", "GCC_")]; + +/// Compiled-in ELF acceptance policy. +#[derive(Debug, Clone)] +pub(crate) struct ElfPolicy { + pub(crate) interpreter: &'static str, + pub(crate) allowed_needed: &'static [&'static str], + pub(crate) glibc_maximum: GlibcVersion, + pub(crate) denied_path_prefixes: &'static [&'static str], +} + +impl ElfPolicy { + /// The reviewed `x86_64-unknown-linux-gnu` policy: glibc 2.34 ceiling. + pub(crate) const fn linux_x86_64_gnu() -> Self { + Self { + interpreter: LINUX_X86_64_INTERPRETER, + allowed_needed: LINUX_X86_64_NEEDED, + glibc_maximum: GlibcVersion::new(2, 34), + denied_path_prefixes: LINUX_X86_64_DENIED_PATH_PREFIXES, + } + } +} + +/// Inspects a final ELF executable against `policy`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Inspect`] with a [`Violation`] when the bytes are +/// not a little-endian 64-bit `x86_64` executable, the interpreter differs, +/// a `DT_NEEDED` entry is outside the allowlist, `DT_RPATH`/`DT_RUNPATH` (or +/// a filter) is present, a version requirement is unparsable or above the +/// glibc ceiling, a section name or debug section is malformed, or a +/// runner-only path is embedded anywhere in the file. Missing debug info is +/// recorded, not rejected here; see [`ElfFacts::require_debug_sections`]. +/// +/// Structural inspection records final-file facts, not complete build-path +/// provenance. The GNU policy permits runtime `/tmp/` constants; its owning +/// recipe must additionally enforce the filesystem restrictions and scan its +/// actual bound host and execution roots before accepting the artifact. +pub(crate) fn inspect_elf( + bytes: &[u8], + policy: &ElfPolicy, +) -> Result> { + let header = Header::parse(bytes).map_err(|error| malformed("ELF header", error))?; + let endian = header + .endian() + .map_err(|error| malformed("ELF endianness", error))?; + check_identity(header, endian)?; + let interpreter = interpreter(header, endian, bytes)?; + if interpreter != policy.interpreter { + return Err(rejected( + Violation::Interpreter, + format!( + "PT_INTERP is {interpreter:?}, expected {:?}", + policy.interpreter + ), + )); + } + let sections = header + .sections(endian, bytes) + .map_err(|error| malformed("ELF section table", error))?; + loader::validate(header, endian, bytes, §ions)?; + let needed = dynamic_needed(header, endian, bytes, §ions)?; + if let Some(name) = needed + .iter() + .find(|name| !policy.allowed_needed.contains(&name.as_str())) + { + return Err(rejected(Violation::Library, format!("DT_NEEDED {name:?}"))); + } + let version_requirements = version_requirements(endian, bytes, §ions)?; + let max_glibc = check_versions(&version_requirements, &needed, policy.glibc_maximum)?; + let debug_sections = debug_sections(endian, bytes, §ions)?; + reject_denied_paths_with_sections(bytes, policy.denied_path_prefixes, |offset| { + section_at(endian, §ions, offset) + })?; + Ok(ElfFacts { + sha256: sha256_hex(bytes), + interpreter: interpreter.to_owned(), + needed, + version_requirements, + max_glibc, + debug_sections, + }) +} + +/// Names the file-backed section containing one denied-path offset. +fn section_at<'a>(endian: Endianness, sections: &Sections<'a>, offset: usize) -> Option<&'a [u8]> { + let offset = u64::try_from(offset).ok()?; + sections.iter().find_map(|section| { + if section.sh_type(endian) == format::SHT_NOBITS { + return None; + } + let start = section.sh_offset(endian); + let end = start.checked_add(section.sh_size(endian))?; + (start..end) + .contains(&offset) + .then(|| sections.section_name(endian, section).ok()) + .flatten() + }) +} + +/// Returns every section named like a required DWARF section, with the +/// number of file bytes it holds (zero for `SHT_NOBITS`). +/// +/// Recording is not acceptance: [`ElfFacts::require_debug_sections`] decides. +fn debug_sections( + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, +) -> Result, Report> { + let mut found = Vec::new(); + for section in sections.iter() { + let name = sections + .section_name(endian, section) + .map_err(|error| malformed("ELF section name", error))?; + let Some(required) = REQUIRED_DEBUG_SECTIONS + .iter() + .find(|required| required.as_bytes() == name) + else { + continue; + }; + let size = if section.sh_type(endian) == format::SHT_NOBITS { + 0 + } else { + section + .data(endian, bytes) + .map_err(|error| malformed("ELF debug section", error))? + .len() + }; + found.push(ElfDebugSection { + name: (*required).to_owned(), + size, + }); + } + Ok(found) +} + +/// Requires a little-endian `x86_64` `ET_EXEC`/`ET_DYN` file. +fn check_identity(header: &Header, endian: Endianness) -> Result<(), Report> { + let ident = header.e_ident(); + let machine = header.e_machine(endian); + let kind = header.e_type(endian); + if ident.data != format::ELFDATA2LSB + || machine != format::EM_X86_64 + || !matches!(kind, format::ET_EXEC | format::ET_DYN) + { + return Err(rejected( + Violation::Architecture, + format!( + "ELF data {} machine {machine} type {kind}; expected little-endian x86_64 \ + executable", + ident.data + ), + )); + } + Ok(()) +} + +/// Returns the single `PT_INTERP` path. +fn interpreter<'data>( + header: &Header, + endian: Endianness, + bytes: &'data [u8], +) -> Result<&'data str, Report> { + let headers = header + .program_headers(endian, bytes) + .map_err(|error| malformed("ELF program headers", error))?; + let mut found = Vec::new(); + for segment in headers { + if let Some(path) = segment + .interpreter(endian, bytes) + .map_err(|error| malformed("ELF PT_INTERP", error))? + { + found.push(path); + } + } + match found.as_slice() { + [path] => utf8(path, "ELF PT_INTERP"), + _ => Err(rejected( + Violation::Interpreter, + format!("expected exactly one PT_INTERP, found {}", found.len()), + )), + } +} + +/// Returns `DT_NEEDED` names after cross-checking the dynamic section. +fn dynamic_needed( + header: &Header, + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, +) -> Result, Report> { + let segment = single_dynamic_segment(header, endian, bytes)?; + let mut dynamic_sections = sections + .iter() + .filter(|section| section.sh_type(endian) == format::SHT_DYNAMIC); + let (Some(section), None) = (dynamic_sections.next(), dynamic_sections.next()) else { + return Err(malformed( + "ELF dynamic section", + "expected exactly one SHT_DYNAMIC", + )); + }; + if (section.sh_offset(endian), section.sh_size(endian)) != segment { + return Err(malformed( + "ELF dynamic section", + "does not match PT_DYNAMIC", + )); + } + let (entries, link) = section + .dynamic(endian, bytes) + .map_err(|error| malformed("ELF dynamic entries", error))? + .ok_or_else(|| malformed("ELF dynamic entries", "missing"))?; + let strings = linked_strings(endian, bytes, sections, link)?; + let strtab_address = sections + .section(link) + .map_err(|error| malformed("ELF dynamic string table", error))? + .sh_addr(endian); + read_dynamic_entries(entries, endian, strings, strtab_address) +} + +/// Returns `(p_offset, p_filesz)` of the single `PT_DYNAMIC` segment. +fn single_dynamic_segment( + header: &Header, + endian: Endianness, + bytes: &[u8], +) -> Result<(u64, u64), Report> { + let headers = header + .program_headers(endian, bytes) + .map_err(|error| malformed("ELF program headers", error))?; + let mut dynamic = headers + .iter() + .filter(|segment| segment.p_type(endian) == format::PT_DYNAMIC); + match (dynamic.next(), dynamic.next()) { + (Some(segment), None) => Ok((segment.p_offset(endian), segment.p_filesz(endian))), + _ => Err(rejected( + Violation::Interpreter, + "expected exactly one PT_DYNAMIC: not a dynamically linked executable", + )), + } +} + +/// Reads the dynamic entries up to `DT_NULL`. +fn read_dynamic_entries( + entries: &[format::Dyn64], + endian: Endianness, + strings: StringTable<'_>, + strtab_address: u64, +) -> Result, Report> { + let mut needed = Vec::new(); + let mut strtab = None; + let mut terminated = false; + for entry in entries { + let tag = entry.tag32(endian); + match tag { + Some(format::DT_NULL) => { + terminated = true; + break; + } + Some(format::DT_STRTAB) => strtab = Some(entry.d_val(endian)), + Some(format::DT_NEEDED) => needed.push(dynamic_string(entry, endian, strings)?), + Some(format::DT_RPATH | format::DT_RUNPATH) => { + let path = dynamic_string(entry, endian, strings)?; + return Err(rejected( + Violation::SearchPath, + format!("DT_RPATH/DT_RUNPATH {path:?}"), + )); + } + Some( + format::DT_AUXILIARY | format::DT_FILTER | format::DT_AUDIT | format::DT_DEPAUDIT, + ) => { + return Err(rejected( + Violation::Library, + "additional loader library mechanism present", + )); + } + _ => {} + } + } + if !terminated || strtab != Some(strtab_address) { + return Err(malformed( + "ELF dynamic string table", + "DT_STRTAB does not match the linked section", + )); + } + Ok(needed) +} + +/// Decodes a string-valued dynamic entry. +fn dynamic_string( + entry: &format::Dyn64, + endian: Endianness, + strings: StringTable<'_>, +) -> Result> { + let raw = entry + .string(endian, strings) + .map_err(|error| malformed("ELF dynamic string", error))?; + utf8(raw, "ELF dynamic string").map(str::to_owned) +} + +/// Returns the string table linked from a section. +fn linked_strings<'data>( + endian: Endianness, + bytes: &'data [u8], + sections: &Sections<'data>, + link: SectionIndex, +) -> Result, Report> { + if link == SectionIndex(0) { + return Err(malformed("ELF string table", "missing link")); + } + sections + .strings(endian, bytes, link) + .map_err(|error| malformed("ELF string table", error)) +} + +/// Returns every `.gnu.version_r` requirement, sorted and de-duplicated. +fn version_requirements( + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, +) -> Result, Report> { + let (mut needs, link) = sections + .gnu_verneed(endian, bytes) + .map_err(|error| malformed("ELF version requirements", error))? + .ok_or_else(|| { + rejected( + Violation::SymbolVersion, + "no .gnu.version_r: the glibc ceiling cannot be checked", + ) + })?; + let strings = linked_strings(endian, bytes, sections, link)?; + let mut found = BTreeSet::new(); + let parse_error = |error| malformed("ELF version requirement", error); + while let Some((need, mut auxiliaries)) = needs.next().map_err(parse_error)? { + let file = utf8( + need.file(endian, strings).map_err(parse_error)?, + "ELF vn_file", + )?; + while let Some(auxiliary) = auxiliaries.next().map_err(parse_error)? { + let name = utf8( + auxiliary.name(endian, strings).map_err(parse_error)?, + "ELF vna_name", + )?; + found.insert(VersionRequirement { + file: file.to_owned(), + name: name.to_owned(), + }); + } + } + Ok(found.into_iter().collect()) +} + +/// Checks every requirement and returns the highest `GLIBC_` version. +fn check_versions( + requirements: &[VersionRequirement], + needed: &[String], + maximum: GlibcVersion, +) -> Result> { + let mut highest = None; + for requirement in requirements { + if !needed.contains(&requirement.file) { + return Err(rejected( + Violation::SymbolVersion, + format!( + "version requirement on {:?}, which is not DT_NEEDED", + requirement.file + ), + )); + } + if let Some(version) = glibc_requirement(requirement)? { + if version > maximum { + return Err(rejected( + Violation::SymbolVersion, + format!( + "{} requires GLIBC_{version} > ceiling {maximum}", + requirement.file + ), + )); + } + highest = highest.max(Some(version)); + } + } + highest.ok_or_else(|| { + rejected( + Violation::SymbolVersion, + "no GLIBC_ requirement was observed", + ) + }) +} + +/// Classifies one requirement: a parsed `GLIBC_` version, an accepted +/// non-glibc name (`None`), or a rejection. +fn glibc_requirement( + requirement: &VersionRequirement, +) -> Result, Report> { + if let Some(suffix) = requirement.name.strip_prefix(GLIBC_PREFIX) { + return GlibcVersion::parse(suffix).map(Some).ok_or_else(|| { + rejected( + Violation::SymbolVersion, + format!( + "unparsable version {:?} from {:?}", + requirement.name, requirement.file + ), + ) + }); + } + let accepted = OTHER_VERSION_PREFIXES + .iter() + .any(|(file, prefix)| requirement.file == *file && requirement.name.starts_with(prefix)); + if accepted { + Ok(None) + } else { + Err(rejected( + Violation::SymbolVersion, + format!( + "unexpected version {:?} from {:?}", + requirement.name, requirement.file + ), + )) + } +} diff --git a/crates/trusted-server-release/src/inspect/elf/loader.rs b/crates/trusted-server-release/src/inspect/elf/loader.rs new file mode 100644 index 000000000..83d933968 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/elf/loader.rs @@ -0,0 +1,301 @@ +//! Binds section observations to the bytes selected by the ELF loader. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use object::Endianness; +use object::elf as format; +use object::read::elf::{Dyn as _, FileHeader as _, ProgramHeader as _, SectionHeader as _}; + +use super::{Header, Sections}; +use crate::error::ReleaseError; +use crate::inspect::{Violation, malformed, rejected}; + +type Section = format::SectionHeader64; + +fn invalid(detail: impl std::fmt::Display) -> Report { + malformed("ELF loader metadata", detail) +} + +fn unique_section<'a>( + sections: &'a Sections<'_>, + endian: Endianness, + kind: u32, +) -> Result<(usize, &'a Section), Report> { + let mut found = sections + .iter() + .enumerate() + .filter(|(_, section)| section.sh_type(endian) == kind); + match (found.next(), found.next()) { + (Some(section), None) => Ok(section), + _ => Err(invalid(format!("expected one section of type {kind}"))), + } +} + +/// Resolves an entire virtual range through exactly one file-backed load. +fn mapped_offset( + header: &Header, + endian: Endianness, + bytes: &[u8], + address: u64, + size: u64, +) -> Result> { + let end = address + .checked_add(size) + .ok_or_else(|| invalid("address overflow"))?; + let programs = header.program_headers(endian, bytes).map_err(invalid)?; + let mut offsets = Vec::new(); + for program in programs { + if program.p_type(endian) != format::PT_LOAD { + continue; + } + let start = program.p_vaddr(endian); + let limit = start + .checked_add(program.p_filesz(endian)) + .ok_or_else(|| invalid("segment overflow"))?; + let memory_end = start + .checked_add(program.p_memsz(endian)) + .ok_or_else(|| invalid("memory range overflow"))?; + // Linux x86_64 mappings include whole 4 KiB pages. A second segment + // can replace an earlier segment's bytes even before its nominal + // start, or zero-fill them after its file-backed portion. + let page_start = start & !4095; + let page_end = memory_end + .checked_add(4095) + .ok_or_else(|| invalid("page range overflow"))? + & !4095; + if program.p_filesz(endian) > program.p_memsz(endian) { + return Err(invalid("load file size exceeds its memory size")); + } + if page_start < end && page_end > address { + if address < start || end > limit { + return Err(invalid("load mapping partially overlaps observed metadata")); + } + let offset = program + .p_offset(endian) + .checked_add(address - start) + .ok_or_else(|| invalid("file offset overflow"))?; + let file_end = offset + .checked_add(size) + .ok_or_else(|| invalid("file range overflow"))?; + if file_end > bytes.len() as u64 || program.p_filesz(endian) > program.p_memsz(endian) { + return Err(invalid("load range exceeds file or memory")); + } + offsets.push(offset); + } + } + match offsets.as_slice() { + [offset] => Ok(*offset), + _ => Err(invalid( + "virtual range has no unique file-backed load mapping", + )), + } +} + +fn required(tags: &BTreeMap, tag: u32) -> Result> { + tags.get(&tag) + .copied() + .ok_or_else(|| invalid(format!("missing dynamic tag {tag}"))) +} + +fn dynamic_tags( + header: &Header, + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, +) -> Result, Report> { + let (_, section) = unique_section(sections, endian, format::SHT_DYNAMIC)?; + let programs = header.program_headers(endian, bytes).map_err(invalid)?; + let mut segments = programs + .iter() + .filter(|program| program.p_type(endian) == format::PT_DYNAMIC); + let (Some(segment), None) = (segments.next(), segments.next()) else { + return Err(invalid("expected one dynamic segment")); + }; + if mapped_offset( + header, + endian, + bytes, + segment.p_vaddr(endian), + segment.p_filesz(endian), + )? != section.sh_offset(endian) + || segment.p_offset(endian) != section.sh_offset(endian) + || segment.p_filesz(endian) != section.sh_size(endian) + { + return Err(invalid("dynamic section differs from loader range")); + } + let (entries, _) = section + .dynamic(endian, bytes) + .map_err(invalid)? + .ok_or_else(|| invalid("missing dynamic entries"))?; + let mut tags = BTreeMap::new(); + for entry in entries { + let tag = entry + .tag32(endian) + .ok_or_else(|| invalid("unsupported dynamic tag width"))?; + if tag == format::DT_NULL { + return Ok(tags); + } + if matches!( + tag, + format::DT_AUDIT | format::DT_DEPAUDIT | format::DT_AUXILIARY | format::DT_FILTER + ) { + return Err(rejected( + Violation::Library, + "additional loader library mechanism", + )); + } + if tag != format::DT_NEEDED && tags.insert(tag, entry.d_val(endian)).is_some() { + return Err(invalid(format!("duplicate dynamic singleton {tag}"))); + } + } + Err(invalid("dynamic table has no terminator")) +} + +fn check_section( + header: &Header, + endian: Endianness, + bytes: &[u8], + section: &Section, + address: u64, +) -> Result<(), Report> { + if section.sh_addr(endian) != address + || mapped_offset(header, endian, bytes, address, section.sh_size(endian))? + != section.sh_offset(endian) + { + return Err(invalid("section differs from loader-selected bytes")); + } + Ok(()) +} + +pub(super) fn validate( + header: &Header, + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, +) -> Result<(), Report> { + let tags = dynamic_tags(header, endian, bytes, sections)?; + let (_, dynamic) = unique_section(sections, endian, format::SHT_DYNAMIC)?; + let string_index = dynamic.sh_link(endian) as usize; + let strings = sections + .iter() + .nth(string_index) + .ok_or_else(|| invalid("missing dynamic strings"))?; + if strings.sh_type(endian) != format::SHT_STRTAB + || strings.sh_size(endian) != required(&tags, format::DT_STRSZ)? + { + return Err(invalid("dynamic string table type or length mismatch")); + } + check_section( + header, + endian, + bytes, + strings, + required(&tags, format::DT_STRTAB)?, + )?; + let (_, needs) = unique_section(sections, endian, format::SHT_GNU_VERNEED)?; + check_section( + header, + endian, + bytes, + needs, + required(&tags, format::DT_VERNEED)?, + )?; + if u64::from(needs.sh_info(endian)) != required(&tags, format::DT_VERNEEDNUM)? + || needs.sh_link(endian) as usize != string_index + { + return Err(invalid("version requirement count or strings mismatch")); + } + validate_symbols(header, endian, bytes, sections, &tags, string_index) +} + +fn validate_symbols( + header: &Header, + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, + tags: &BTreeMap, + string_index: usize, +) -> Result<(), Report> { + let (symbol_index, symbols) = unique_section(sections, endian, format::SHT_DYNSYM)?; + let (_, versions) = unique_section(sections, endian, format::SHT_GNU_VERSYM)?; + check_section( + header, + endian, + bytes, + symbols, + required(tags, format::DT_SYMTAB)?, + )?; + check_section( + header, + endian, + bytes, + versions, + required(tags, format::DT_VERSYM)?, + )?; + if required(tags, format::DT_SYMENT)? != 24 + || symbols.sh_entsize(endian) != 24 + || !symbols.sh_size(endian).is_multiple_of(24) + || symbols.sh_size(endian) < 24 + || symbols.sh_link(endian) as usize != string_index + || versions.sh_link(endian) as usize != symbol_index + || versions.sh_size(endian) != symbols.sh_size(endian) / 12 + { + return Err(invalid("dynamic symbol/version table shape mismatch")); + } + let indices = requirement_indices( + endian, + bytes, + sections, + required(tags, format::DT_VERNEEDNUM)?, + )?; + let symbol_bytes = symbols.data(endian, bytes).map_err(invalid)?; + let version_bytes = versions.data(endian, bytes).map_err(invalid)?; + for (symbol, version) in symbol_bytes + .as_chunks::<24>() + .0 + .iter() + .zip(version_bytes.as_chunks::<2>().0) + { + let index = u16::from_le_bytes([version[0], version[1]]) & 0x7fff; + let undefined = u16::from_le_bytes([symbol[6], symbol[7]]) == format::SHN_UNDEF; + if undefined && index > 1 && !indices.contains(&index) { + return Err(invalid( + "undefined symbol references an unaccounted version", + )); + } + } + Ok(()) +} + +fn requirement_indices( + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, + expected: u64, +) -> Result, Report> { + let (mut needs, _) = sections + .gnu_verneed(endian, bytes) + .map_err(invalid)? + .ok_or_else(|| invalid("missing version requirements"))?; + let mut count = 0; + let mut indices = BTreeSet::new(); + while let Some((need, mut auxiliary)) = needs.next().map_err(invalid)? { + count += 1; + let mut auxiliary_count = 0; + while let Some(version) = auxiliary.next().map_err(invalid)? { + auxiliary_count += 1; + let index = version.vna_other.get(endian) & 0x7fff; + if index <= 1 || !indices.insert(index) { + return Err(invalid("ambiguous version requirement index")); + } + } + if auxiliary_count != need.vn_cnt.get(endian) || auxiliary_count == 0 { + return Err(invalid("version auxiliary count mismatch")); + } + } + if count != expected { + return Err(invalid("version requirement count mismatch")); + } + Ok(indices) +} diff --git a/crates/trusted-server-release/src/inspect/macho.rs b/crates/trusted-server-release/src/inspect/macho.rs new file mode 100644 index 000000000..96dc8a8e5 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/macho.rs @@ -0,0 +1,438 @@ +//! Mach-O inspection for thin 64-bit macOS executables. +//! +//! Beyond identity, loader and libraries, the inspector records the +//! `LC_UUID`, the symbol table and its debug map, and the code signature +//! form. Every `N_OSO` debug-map object path must be relative and carry a +//! zero modification time (the recipe links with `-oso_prefix` and +//! `ZERO_AR_DATE=1`). An arm64 executable must carry the linker's ad-hoc +//! signature, which macOS requires to run it; an `x86_64` executable may be +//! unsigned or carry the same signature. + +mod signature; + +use error_stack::Report; +use object::macho::{self as format, MachHeader64}; +use object::read::macho::{LoadCommandData, LoadCommandVariant, MachHeader as _, Nlist as _}; +use object::{Endianness, FileKind}; + +use super::{ + MachoFacts, MacosVersion, RUNNER_PATH_PREFIXES, Violation, malformed, reject_denied_paths, + rejected, sha256_hex, utf8, +}; +use crate::error::ReleaseError; + +type Header = MachHeader64; + +/// The only dynamic linker a macOS executable may name. +const MACOS_DYLINKER: &str = "/usr/lib/dyld"; + +/// Reviewed `LC_LOAD_DYLIB` set for the `ts` CLI. +/// +/// Observed with `otool -L` on a local `aarch64-apple-darwin` dev build of +/// `trusted-server-cli`: Security and CoreFoundation (via +/// `security-framework`), libiconv and libSystem. +const MACOS_DYLIBS: &[&str] = &[ + "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation", + "/System/Library/Frameworks/Security.framework/Versions/A/Security", + "/usr/lib/libSystem.B.dylib", + "/usr/lib/libiconv.2.dylib", +]; + +/// Selected macOS CPU type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum MachoCpu { + /// `aarch64-apple-darwin` (`CPU_SUBTYPE_ARM64_ALL`; arm64e is rejected). + #[display("arm64")] + Arm64, + /// `x86_64-apple-darwin` (`CPU_SUBTYPE_X86_64_ALL`). + #[display("x86_64")] + X86_64, +} + +impl MachoCpu { + /// Returns the expected `(cputype, cpusubtype & !CPU_SUBTYPE_MASK)`. + const fn expected(self) -> (u32, u32) { + match self { + Self::Arm64 => (format::CPU_TYPE_ARM64, format::CPU_SUBTYPE_ARM64_ALL), + Self::X86_64 => (format::CPU_TYPE_X86_64, format::CPU_SUBTYPE_X86_64_ALL), + } + } +} + +/// Compiled-in Mach-O acceptance policy. +#[derive(Debug, Clone)] +pub(crate) struct MachoPolicy { + pub(crate) cpu: MachoCpu, + pub(crate) minimum: MacosVersion, + pub(crate) allowed_dylibs: &'static [&'static str], + pub(crate) denied_path_prefixes: &'static [&'static str], +} + +impl MachoPolicy { + /// The reviewed macOS policy for `cpu`: deployment target exactly 13.0. + pub(crate) const fn macos(cpu: MachoCpu) -> Self { + Self { + cpu, + minimum: MacosVersion::new(13, 0), + allowed_dylibs: MACOS_DYLIBS, + denied_path_prefixes: RUNNER_PATH_PREFIXES, + } + } +} + +/// The code signature form an accepted executable carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum CodeSignature { + /// No `LC_CODE_SIGNATURE`; accepted for `x86_64` only. + #[display("unsigned")] + Unsigned, + /// The linker's ad-hoc signature: one code directory with flags + /// `0x20002`, no CMS blob and no team identifier. + #[display("linker-signed-ad-hoc")] + LinkerSignedAdhoc, +} + +/// Symbol table counts after every debug-map entry was checked. +#[derive(Default)] +struct SymbolSummary { + symbols: usize, + debug_map_objects: usize, +} + +/// Load-command observations accumulated before policy checks. +#[derive(Default)] +struct Observed { + build_versions: Vec<(u32, MacosVersion, MacosVersion)>, + dylinkers: Vec, + dylibs: Vec, + uuids: Vec<[u8; 16]>, + symbol_tables: Vec, + signatures: Vec<(u32, u32)>, +} + +/// Inspects a final Mach-O executable against `policy`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Inspect`] with a [`Violation`] when the bytes are +/// fat, 32-bit, big-endian or not `MH_EXECUTE`; the CPU differs; there is +/// not exactly one macOS `LC_BUILD_VERSION` with `minos` equal to the floor +/// (or an `LC_VERSION_MIN_*` is present); the dynamic linker is not +/// `/usr/lib/dyld`; a dylib is outside the allowlist or loaded weak, +/// re-exported, lazy or upward; `LC_RPATH`/`LC_DYLD_ENVIRONMENT` is present; +/// a runner-only path is embedded anywhere in the file; a debug-map object +/// path is absolute or time-stamped; there is not exactly one `LC_UUID` +/// (the linker always writes one); `LC_SYMTAB` or `LC_CODE_SIGNATURE` is +/// duplicated; the code signature is not the linker's ad-hoc form; or an +/// arm64 executable is unsigned. +pub(crate) fn inspect_macho( + bytes: &[u8], + policy: &MachoPolicy, +) -> Result> { + check_container(bytes)?; + let header = Header::parse(bytes, 0).map_err(|error| malformed("Mach-O header", error))?; + let endian = header + .endian() + .map_err(|error| malformed("Mach-O endianness", error))?; + check_identity(header, endian, policy.cpu)?; + let mut observed = load_commands(header, endian, bytes)?; + let (minos, sdk) = deployment_target(&observed, policy.minimum)?; + if observed.dylinkers.as_slice() != [MACOS_DYLINKER] { + return Err(rejected( + Violation::Interpreter, + format!( + "LC_LOAD_DYLINKER {:?}, expected [{MACOS_DYLINKER:?}]", + observed.dylinkers + ), + )); + } + if let Some(name) = observed + .dylibs + .iter() + .find(|name| !policy.allowed_dylibs.contains(&name.as_str())) + { + return Err(rejected( + Violation::Library, + format!("LC_LOAD_DYLIB {name:?}"), + )); + } + reject_denied_paths(bytes, policy.denied_path_prefixes)?; + let signature = code_signature(&observed, bytes, policy.cpu)?; + let [uuid] = observed.uuids.as_slice() else { + return Err(rejected( + Violation::MachoIdentity, + format!( + "expected exactly one LC_UUID, found {}", + observed.uuids.len() + ), + )); + }; + let uuid = *uuid; + let summary = match observed.symbol_tables.as_mut_slice() { + [] => SymbolSummary::default(), + [summary] => std::mem::take(summary), + _ => { + return Err(rejected( + Violation::MachoDebugMap, + "more than one LC_SYMTAB", + )); + } + }; + Ok(MachoFacts { + sha256: sha256_hex(bytes), + loader: observed + .dylinkers + .pop() + .expect("should retain the validated dynamic linker"), + cpu: policy.cpu, + minos, + sdk, + dylibs: observed.dylibs, + uuid, + symbols: summary.symbols, + debug_map_objects: summary.debug_map_objects, + signature, + }) +} + +/// Classifies the code signature and applies the per-CPU requirement. +fn code_signature( + observed: &Observed, + bytes: &[u8], + cpu: MachoCpu, +) -> Result> { + let signature = match observed.signatures.as_slice() { + [] => CodeSignature::Unsigned, + [(offset, size)] => { + let start = usize::try_from(*offset).ok(); + let end = start.and_then(|start| start.checked_add(usize::try_from(*size).ok()?)); + let blob = start + .zip(end) + .and_then(|(start, end)| bytes.get(start..end)) + .ok_or_else(|| { + rejected( + Violation::CodeSignature, + "LC_CODE_SIGNATURE data lies outside the file", + ) + })?; + signature::check_linker_signed(blob)?; + CodeSignature::LinkerSignedAdhoc + } + _ => { + return Err(rejected( + Violation::CodeSignature, + "more than one LC_CODE_SIGNATURE", + )); + } + }; + if cpu == MachoCpu::Arm64 && signature == CodeSignature::Unsigned { + return Err(rejected( + Violation::CodeSignature, + "an arm64 executable must carry the linker's ad-hoc signature", + )); + } + Ok(signature) +} + +/// Counts the symbol table, requiring every `N_OSO` debug-map entry to name +/// a non-empty relative UTF-8 path with a zero modification time. +fn symbol_summary( + symtab: &format::SymtabCommand, + endian: Endianness, + bytes: &[u8], +) -> Result> { + let parse_error = |error| malformed("Mach-O symbol table", error); + // The parser rejects an empty slice it cannot align, so an empty table + // is counted without reading it. + if symtab.nsyms.get(endian) == 0 { + return Ok(SymbolSummary::default()); + } + let table = symtab + .symbols::(endian, bytes) + .map_err(parse_error)?; + let mut summary = SymbolSummary::default(); + for symbol in table.iter() { + summary.symbols += 1; + if symbol.n_type() != format::N_OSO { + continue; + } + let name = symbol.name(endian, table.strings()).map_err(parse_error)?; + let name = utf8(name, "Mach-O debug-map object")?; + if name.is_empty() || name.starts_with('/') { + return Err(rejected( + Violation::MachoDebugMap, + format!("debug-map object {name:?} is not a relative path"), + )); + } + let modified = symbol.n_value(endian); + if modified != 0 { + return Err(rejected( + Violation::MachoDebugMap, + format!("debug-map object {name:?} records modification time {modified}"), + )); + } + summary.debug_map_objects += 1; + } + Ok(summary) +} + +/// Requires a thin 64-bit Mach-O container. +fn check_container(bytes: &[u8]) -> Result<(), Report> { + match FileKind::parse(bytes) { + Ok(FileKind::MachO64) => Ok(()), + Ok(kind @ (FileKind::MachO32 | FileKind::MachOFat32 | FileKind::MachOFat64)) => { + Err(rejected( + Violation::Architecture, + format!("{kind:?}; expected thin 64-bit Mach-O"), + )) + } + Ok(kind) => Err(malformed("Mach-O container", format!("{kind:?}"))), + Err(error) => Err(malformed("Mach-O container", error)), + } +} + +/// Requires a little-endian `MH_EXECUTE` for the selected CPU. +fn check_identity( + header: &Header, + endian: Endianness, + cpu: MachoCpu, +) -> Result<(), Report> { + let observed = ( + header.cputype(endian), + header.cpusubtype(endian) & !format::CPU_SUBTYPE_MASK, + ); + let file_type = header.filetype(endian); + if endian != Endianness::Little || observed != cpu.expected() || file_type != format::MH_EXECUTE + { + return Err(rejected( + Violation::Architecture, + format!( + "Mach-O {endian:?} cpu {:#x}/{:#x} filetype {file_type}; expected little-endian \ + {cpu} MH_EXECUTE", + observed.0, observed.1 + ), + )); + } + Ok(()) +} + +/// Walks every load command, rejecting forbidden kinds immediately. +fn load_commands( + header: &Header, + endian: Endianness, + bytes: &[u8], +) -> Result> { + let parse_error = |error| malformed("Mach-O load command", error); + let mut commands = header + .load_commands(endian, bytes, 0) + .map_err(parse_error)?; + let mut observed = Observed::default(); + while let Some(command) = commands.next().map_err(parse_error)? { + record_command(&command, endian, bytes, &mut observed)?; + } + Ok(observed) +} + +/// Records or rejects one load command. +fn record_command( + command: &LoadCommandData<'_, Endianness>, + endian: Endianness, + bytes: &[u8], + observed: &mut Observed, +) -> Result<(), Report> { + let parse_error = |error| malformed("Mach-O load command", error); + match command.variant().map_err(parse_error)? { + LoadCommandVariant::Uuid(uuid) => observed.uuids.push(uuid.uuid), + LoadCommandVariant::Symtab(symtab) => observed + .symbol_tables + .push(symbol_summary(symtab, endian, bytes)?), + LoadCommandVariant::LinkeditData(data) if command.cmd() == format::LC_CODE_SIGNATURE => { + observed + .signatures + .push((data.dataoff.get(endian), data.datasize.get(endian))); + } + LoadCommandVariant::BuildVersion(build) => observed.build_versions.push(( + build.platform.get(endian), + MacosVersion::from_encoded(build.minos.get(endian)), + MacosVersion::from_encoded(build.sdk.get(endian)), + )), + LoadCommandVariant::LoadDylinker(dylinker) => { + let name = command.string(endian, dylinker.name).map_err(parse_error)?; + observed + .dylinkers + .push(utf8(name, "Mach-O dylinker")?.to_owned()); + } + LoadCommandVariant::Dylib(dylib) => { + let name = command + .string(endian, dylib.dylib.name) + .map_err(parse_error)?; + observed.dylibs.push(plain_dylib(command.cmd(), name)?); + } + LoadCommandVariant::Rpath(rpath) => { + let path = command.string(endian, rpath.path).map_err(parse_error)?; + return Err(rejected( + Violation::SearchPath, + format!("LC_RPATH {:?}", String::from_utf8_lossy(path)), + )); + } + other => reject_forbidden(&other)?, + } + Ok(()) +} + +/// Rejects load commands that carry no string but are never acceptable in a +/// release executable. +fn reject_forbidden( + variant: &LoadCommandVariant<'_, Endianness>, +) -> Result<(), Report> { + let (violation, detail) = match variant { + LoadCommandVariant::VersionMin(_) => { + (Violation::DeploymentTarget, "LC_VERSION_MIN_* present") + } + LoadCommandVariant::IdDylib(_) | LoadCommandVariant::PreboundDylib(_) => { + (Violation::LinkKind, "dylib identity in an executable") + } + LoadCommandVariant::DyldEnvironment(_) => { + (Violation::SearchPath, "LC_DYLD_ENVIRONMENT present") + } + _ => return Ok(()), + }; + Err(rejected(violation, detail)) +} + +/// Returns the install name of a plain `LC_LOAD_DYLIB`; weak, re-exported, +/// lazy and upward loads are rejected. +fn plain_dylib(cmd: u32, name: &[u8]) -> Result> { + let name = utf8(name, "Mach-O dylib")?; + if cmd != format::LC_LOAD_DYLIB { + return Err(rejected( + Violation::LinkKind, + format!("load command {cmd:#x} for {name:?}"), + )); + } + Ok(name.to_owned()) +} + +/// Requires exactly one macOS `LC_BUILD_VERSION` whose `minos` equals the +/// floor; returns `(minos, sdk)`. +fn deployment_target( + observed: &Observed, + minimum: MacosVersion, +) -> Result<(MacosVersion, MacosVersion), Report> { + let [(platform, minos, sdk)] = observed.build_versions.as_slice() else { + return Err(rejected( + Violation::DeploymentTarget, + format!( + "expected exactly one LC_BUILD_VERSION, found {}", + observed.build_versions.len() + ), + )); + }; + if *platform != format::PLATFORM_MACOS || *minos != minimum { + return Err(rejected( + Violation::DeploymentTarget, + format!("platform {platform} minos {minos}; expected macOS minos {minimum}"), + )); + } + Ok((*minos, *sdk)) +} diff --git a/crates/trusted-server-release/src/inspect/macho/signature.rs b/crates/trusted-server-release/src/inspect/macho/signature.rs new file mode 100644 index 000000000..f6b0b7cf6 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/macho/signature.rs @@ -0,0 +1,164 @@ +//! Embedded code signature parsing for linker-signed ad-hoc executables. +//! +//! `ld` signs an arm64 executable ad hoc as it links it: a big-endian +//! `SuperBlob` (`0xfade0cc0`) indexing one `CodeDirectory` (`0xfade0c02`) +//! whose flags are exactly `CS_ADHOC | CS_LINKER_SIGNED` (`0x20002`), and at +//! most one requirements blob, which must be the empty set (`0xfade0c01`, +//! length 12, count 0). A `codesign` identity would add a CMS +//! blob wrapper and a team identifier; both are rejected, as is any other +//! slot. The hashes are not recomputed: this records the signature's form, +//! not its validity, which the actual macOS startup exercises. + +use error_stack::Report; + +use super::super::{Violation, rejected}; +use crate::error::ReleaseError; + +/// Embedded signature `SuperBlob` magic. +pub(crate) const CSMAGIC_EMBEDDED_SIGNATURE: u32 = 0xfade_0cc0; +/// `CodeDirectory` blob magic. +pub(crate) const CSMAGIC_CODEDIRECTORY: u32 = 0xfade_0c02; +/// Slot of the primary `CodeDirectory`. +pub(crate) const CSSLOT_CODEDIRECTORY: u32 = 0; +/// Slot of the internal requirements blob. +pub(crate) const CSSLOT_REQUIREMENTS: u32 = 2; +/// Requirements set blob magic. +pub(crate) const CSMAGIC_REQUIREMENTS: u32 = 0xfade_0c01; +/// Length of a requirements set with no entries: magic, length and count. +const EMPTY_REQUIREMENTS_LENGTH: u32 = 12; +/// Slot of the CMS signature blob wrapper a signing identity adds. +pub(crate) const CSSLOT_SIGNATURESLOT: u32 = 0x1_0000; +/// `CS_ADHOC | CS_LINKER_SIGNED`, the only accepted directory flags. +pub(crate) const LINKER_SIGNED_ADHOC: u32 = 0x2_0002; +/// First `CodeDirectory` version carrying a team identifier offset. +const SUPPORTS_TEAM_ID: u32 = 0x2_0200; +/// Byte offset of the team identifier offset in a `CodeDirectory`. +const TEAM_OFFSET_FIELD: usize = 48; +/// Bytes of the `SuperBlob` header: magic, length and count. +const SUPERBLOB_HEADER: usize = 12; +/// Bytes of one `SuperBlob` index entry: slot type and offset. +const INDEX_ENTRY: usize = 8; + +/// Reads a big-endian `u32` at `offset`. +fn be_u32(bytes: &[u8], offset: usize) -> Option { + let end = offset.checked_add(4)?; + let word: [u8; 4] = bytes.get(offset..end)?.try_into().ok()?; + Some(u32::from_be_bytes(word)) +} + +fn signature_rejected(detail: impl std::fmt::Display) -> Report { + rejected(Violation::CodeSignature, detail) +} + +/// Requires `blob`, the bytes `LC_CODE_SIGNATURE` names, to be exactly one +/// linker-signed ad-hoc `CodeDirectory` with no CMS blob or team identifier. +/// +/// # Errors +/// +/// Returns [`Violation::CodeSignature`] for a malformed or truncated blob, +/// another magic, a CMS signature slot, any slot other than the directory +/// and requirements, not exactly one directory, other directory flags, a +/// team identifier, or more than one or a non-empty requirements blob. +pub(super) fn check_linker_signed(blob: &[u8]) -> Result<(), Report> { + let truncated = || signature_rejected("code signature is truncated"); + if be_u32(blob, 0) != Some(CSMAGIC_EMBEDDED_SIGNATURE) { + return Err(signature_rejected( + "code signature is not an embedded SuperBlob", + )); + } + let length = be_u32(blob, 4) + .and_then(|length| usize::try_from(length).ok()) + .filter(|length| (SUPERBLOB_HEADER..=blob.len()).contains(length)) + .ok_or_else(truncated)?; + let blob = &blob[..length]; + let count = be_u32(blob, 8) + .and_then(|count| usize::try_from(count).ok()) + .ok_or_else(truncated)?; + let index_end = count + .checked_mul(INDEX_ENTRY) + .and_then(|bytes| bytes.checked_add(SUPERBLOB_HEADER)) + .filter(|end| *end <= blob.len()) + .ok_or_else(truncated)?; + let mut directories = 0_usize; + let mut requirements = 0_usize; + for entry in (SUPERBLOB_HEADER..index_end).step_by(INDEX_ENTRY) { + let slot = be_u32(blob, entry).ok_or_else(truncated)?; + let offset = be_u32(blob, entry + 4) + .and_then(|offset| usize::try_from(offset).ok()) + .ok_or_else(truncated)?; + match slot { + CSSLOT_CODEDIRECTORY => { + directories += 1; + check_directory(blob.get(offset..).ok_or_else(truncated)?)?; + } + CSSLOT_REQUIREMENTS => { + requirements += 1; + check_empty_requirements(blob.get(offset..).ok_or_else(truncated)?)?; + } + CSSLOT_SIGNATURESLOT => { + return Err(signature_rejected( + "code signature carries a CMS signature, not a linker ad-hoc signature", + )); + } + other => { + return Err(signature_rejected(format!( + "code signature holds unexpected slot {other:#x}" + ))); + } + } + } + if directories != 1 { + return Err(signature_rejected(format!( + "code signature holds {directories} code directories, expected exactly one" + ))); + } + if requirements > 1 { + return Err(signature_rejected( + "code signature holds more than one requirements blob", + )); + } + Ok(()) +} + +/// Requires an empty requirements set: magic `0xfade0c01`, length 12 and +/// a zero count. +fn check_empty_requirements(requirements: &[u8]) -> Result<(), Report> { + let empty = be_u32(requirements, 0) == Some(CSMAGIC_REQUIREMENTS) + && be_u32(requirements, 4) == Some(EMPTY_REQUIREMENTS_LENGTH) + && be_u32(requirements, 8) == Some(0); + if !empty { + return Err(signature_rejected( + "code signature requirements are not the empty set", + )); + } + Ok(()) +} + +/// Requires one `CodeDirectory` with the linker-signed ad-hoc flags and no +/// team identifier. +fn check_directory(directory: &[u8]) -> Result<(), Report> { + let truncated = || signature_rejected("code directory is truncated"); + if be_u32(directory, 0) != Some(CSMAGIC_CODEDIRECTORY) { + return Err(signature_rejected("code directory has another magic")); + } + let length = be_u32(directory, 4) + .and_then(|length| usize::try_from(length).ok()) + .filter(|length| *length <= directory.len()) + .ok_or_else(truncated)?; + let directory = &directory[..length]; + let version = be_u32(directory, 8).ok_or_else(truncated)?; + let flags = be_u32(directory, 12).ok_or_else(truncated)?; + if flags != LINKER_SIGNED_ADHOC { + return Err(signature_rejected(format!( + "code directory flags {flags:#x}, expected linker-signed ad-hoc \ + {LINKER_SIGNED_ADHOC:#x}" + ))); + } + if version >= SUPPORTS_TEAM_ID { + let team = be_u32(directory, TEAM_OFFSET_FIELD).ok_or_else(truncated)?; + if team != 0 { + return Err(signature_rejected("code directory names a team identifier")); + } + } + Ok(()) +} 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 000000000..2ddea479b --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests.rs @@ -0,0 +1,144 @@ +//! 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 elf; +mod macho; +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") +} + +/// Asserts every strict prefix of `bytes` is rejected without panicking. +fn assert_truncations_rejected( + bytes: &[u8], + inspect: impl Fn(&[u8]) -> Result>, +) { + for length in 0..bytes.len() { + assert!( + inspect(&bytes[..length]).is_err(), + "should reject the input truncated to {length} of {} bytes", + bytes.len() + ); + } +} + +#[test] +fn glibc_versions_parse_numerically() { + assert_eq!(GlibcVersion::parse("2.34"), Some(GlibcVersion::new(2, 34))); + assert!( + GlibcVersion::parse("2.2.5").expect("should parse three components") + < GlibcVersion::new(2, 3), + "should compare 2.2.5 below 2.3" + ); + assert!( + GlibcVersion::new(2, 9) < GlibcVersion::new(2, 34), + "should compare minors numerically, not lexically" + ); + for text in [ + "", + "2", + "PRIVATE", + "ABI_DT_RELR", + "2.34.x", + "2.+4", + "2.034", + "2..34", + "1.2.3.4", + ] { + assert_eq!(GlibcVersion::parse(text), None, "should reject {text:?}"); + } +} + +#[test] +fn macos_versions_decode_nibbles() { + let version = MacosVersion::from_encoded(0x000d_0100); + assert_eq!(version.to_string(), "13.1.0", "should decode xxxx.yy.zz"); + assert_eq!( + MacosVersion::from_encoded(0x000d_0000), + MacosVersion::new(13, 0) + ); +} + +#[test] +fn denied_path_scan_reports_offsets() { + let bytes = b"ok /usr/lib x /home/runner/work y /tmp/abc /tmpfoo"; + let hits: Vec<_> = find_denied_paths(bytes, RUNNER_PATH_PREFIXES).collect(); + assert_eq!( + hits, + vec![ + PathHit { + prefix: "/home/", + offset: 14 + }, + PathHit { + prefix: "/tmp/", + offset: 34 + }, + ], + "should report each runner-only prefix with its offset" + ); + assert!( + find_denied_paths( + b"serde/src/private/de.rs /rust/deps/x/src/lib.rs", + RUNNER_PATH_PREFIXES + ) + .next() + .is_none(), + "should not match crate-relative or remapped paths" + ); + let report = reject_denied_paths(bytes, RUNNER_PATH_PREFIXES) + .expect_err("should reject embedded runner paths"); + assert!( + format!("{report:?}").contains("/home/@0xe"), + "should report the hit offset: {report:?}" + ); +} + +#[test] +fn denied_path_contexts_are_bounded_and_capped_without_weakening_rejection() { + let mut bytes = vec![b'x'; 4096]; + for offset in (128..4096).step_by(128) { + bytes[offset..offset + 6].copy_from_slice(b"/tmp/\n"); + } + let report = reject_denied_paths(&bytes, RUNNER_PATH_PREFIXES) + .expect_err("should still reject every denied path"); + let diagnostic = format!("{report:?}"); + assert!( + diagnostic.contains("31 runner-only path occurrence(s)"), + "should count all occurrences: {diagnostic}" + ); + assert_eq!( + diagnostic.matches("context[").count(), + REPORTED_PATH_HITS, + "should report at most sixteen contexts" + ); + assert!( + diagnostic.contains("context[0x70..0xd0]"), + "should bound each context to sixteen bytes before and eighty after the hit" + ); + assert!( + diagnostic.contains("/tmp/\\n"), + "should escape raw control bytes" + ); + assert!( + diagnostic.len() < 10_000, + "should bound output even for many hits" + ); +} 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 000000000..dfa5c96da --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/builders.rs @@ -0,0 +1,764 @@ +//! Minimal hand-built ELF, Mach-O and Wasm images. +//! +//! The images contain only the structures the inspectors read; they are not +//! runnable. Building them in-process keeps the fixtures host-independent. + +mod macho_data; + +use object::elf as elf_format; +use object::macho as macho_format; + +pub(crate) use macho_data::{code_signature, debug_map_symbols, linker_signature}; + +/// One non-loaded ELF section `(name, sh_type, payload)`. +pub(crate) type ExtraSection = (&'static str, u32, &'static [u8]); + +/// Parameters of a synthetic `x86_64` ELF executable. +pub(crate) struct ElfSpec { + pub(crate) machine: u16, + pub(crate) interpreter: Option<&'static str>, + pub(crate) needed: Vec<&'static str>, + pub(crate) runpath: Option<&'static str>, + pub(crate) requirements: Vec<(&'static str, Vec<&'static str>)>, + pub(crate) payload: &'static [u8], + /// Non-loaded sections after `.shstrtab`. + pub(crate) debug_sections: Vec, +} + +impl ElfSpec { + /// A conforming `ts`-like executable at the glibc 2.34 ceiling. + pub(crate) fn conforming() -> Self { + Self { + machine: elf_format::EM_X86_64, + interpreter: Some("/lib64/ld-linux-x86-64.so.2"), + needed: vec!["libgcc_s.so.1", "libm.so.6", "libc.so.6"], + runpath: None, + requirements: vec![ + ("libgcc_s.so.1", vec!["GCC_3.0", "GCC_4.2.0"]), + ("libc.so.6", vec!["GLIBC_2.2.5", "GLIBC_2.34", "GLIBC_2.18"]), + ], + payload: b"fictional rodata /opt/example/src/main.rs", + debug_sections: vec![ + ( + ".debug_info", + elf_format::SHT_PROGBITS, + b"fictional dwarf info", + ), + ( + ".debug_line", + elf_format::SHT_PROGBITS, + b"fictional dwarf lines", + ), + ], + } + } +} + +/// Little-endian byte writer. +#[derive(Default)] +struct Writer(Vec); + +impl Writer { + fn u16(&mut self, value: u16) { + self.0.extend_from_slice(&value.to_le_bytes()); + } + + fn u32(&mut self, value: u32) { + self.0.extend_from_slice(&value.to_le_bytes()); + } + + fn u64(&mut self, value: u64) { + self.0.extend_from_slice(&value.to_le_bytes()); + } + + fn align(&mut self, alignment: usize) { + while !self.0.len().is_multiple_of(alignment) { + self.0.push(0); + } + } + + fn offset(&self) -> u64 { + u64::try_from(self.0.len()).expect("should fit u64") + } + + /// Appends `bytes` and returns their range. + fn append(&mut self, bytes: &[u8]) -> Range { + let offset = self.offset(); + self.0.extend_from_slice(bytes); + (offset, self.offset() - offset) + } +} + +/// A NUL-separated string table that returns each string's offset. +#[derive(Default)] +struct Strings(Vec); + +impl Strings { + fn new() -> Self { + Self(vec![0]) + } + + fn add(&mut self, text: &str) -> u32 { + let offset = u32::try_from(self.0.len()).expect("should fit u32"); + self.0.extend_from_slice(text.as_bytes()); + self.0.push(0); + offset + } +} + +/// A `(file offset, size)` range; addresses equal offsets in these images. +type Range = (u64, u64); + +/// One ELF section header record. +struct Section { + name: u32, + kind: u32, + range: Range, + link: u32, + info: u32, + entry_size: u64, +} + +const ELF_HEADER_SIZE: u64 = 64; +const PROGRAM_HEADER_SIZE: u64 = 56; +/// Section index of `.dynstr`, linked from `.dynamic` and `.gnu.version_r`. +const DYNSTR_INDEX: u32 = 1; +/// Section index of `.shstrtab`, after the five loader sections. +const SHSTRTAB_INDEX: u16 = 6; + +/// Builds a synthetic ELF64 executable from `spec`. +pub(crate) fn elf(spec: &ElfSpec) -> Vec { + let mut dynstr = Strings::new(); + let mut dynamic = spec + .needed + .iter() + .map(|name| (elf_format::DT_NEEDED, u64::from(dynstr.add(name)))) + .collect::>(); + if let Some(path) = spec.runpath { + dynamic.push((elf_format::DT_RUNPATH, u64::from(dynstr.add(path)))); + } + let verneed = verneed(&spec.requirements, &mut dynstr); + let mut symbols = Writer::default(); + symbols.0.resize(24, 0); + let mut versions = Writer::default(); + versions.u16(0); + for index in 0..spec.requirements.iter().map(|(_, names)| names.len()).sum() { + symbols.u32(dynstr.add(&format!("fictional_symbol_{index}"))); + symbols.0.extend_from_slice(&[0x12, 0]); + symbols.u16(0); + symbols.u64(0); + symbols.u64(0); + versions.u16(u16::try_from(index + 2).expect("should fit version index")); + } + let segments = 3 + u64::from(spec.interpreter.is_some()); + let mut body = Writer::default(); + body.0.resize( + usize::try_from(ELF_HEADER_SIZE + segments * PROGRAM_HEADER_SIZE).expect("should fit"), + 0, + ); + let interp = body.append(&spec.interpreter.map(nul_terminated).unwrap_or_default()); + let dynstr_range = body.append(&dynstr.0); + body.align(4); + let verneed_range = body.append(&verneed); + body.align(8); + let symbols_range = body.append(&symbols.0); + let versions_range = body.append(&versions.0); + body.align(8); + dynamic.push((elf_format::DT_STRTAB, dynstr_range.0)); + dynamic.push((elf_format::DT_STRSZ, dynstr_range.1)); + dynamic.push((elf_format::DT_VERNEED, verneed_range.0)); + dynamic.push((elf_format::DT_VERNEEDNUM, spec.requirements.len() as u64)); + dynamic.push((elf_format::DT_SYMTAB, symbols_range.0)); + dynamic.push((elf_format::DT_SYMENT, 24)); + dynamic.push((elf_format::DT_VERSYM, versions_range.0)); + dynamic.push((elf_format::DT_NULL, 0)); + dynamic.push((elf_format::DT_NULL, 0)); + let dynamic_range = body.append(&encode_dynamic(&dynamic)); + body.0.extend_from_slice(spec.payload); + let debug: Vec<(&str, u32, Range)> = spec + .debug_sections + .iter() + .map(|(name, kind, payload)| (*name, *kind, body.append(payload))) + .collect(); + let count = u32::try_from(spec.requirements.len()).expect("should fit"); + let table = section_table( + &mut body, + [ + dynstr_range, + dynamic_range, + verneed_range, + symbols_range, + versions_range, + ], + count, + &debug, + ); + let mut header = Writer::default(); + write_elf_header(&mut header, spec.machine, segments, body.offset(), &table); + write_sections(&mut body, &table); + if spec.interpreter.is_some() { + program_header(&mut header, elf_format::PT_INTERP, interp); + } + program_header(&mut header, elf_format::PT_DYNAMIC, dynamic_range); + program_header(&mut header, elf_format::PT_NOTE, (0, 0)); + program_header(&mut header, elf_format::PT_LOAD, (0, body.offset())); + body.0[..header.0.len()].copy_from_slice(&header.0); + body.0 +} + +fn nul_terminated(text: &str) -> Vec { + let mut bytes = text.as_bytes().to_vec(); + bytes.push(0); + bytes +} + +fn encode_dynamic(entries: &[(u32, u64)]) -> Vec { + let mut out = Writer::default(); + for (tag, value) in entries { + out.u64(u64::from(*tag)); + out.u64(*value); + } + out.0 +} + +/// Appends `.shstrtab` to `body` and returns the non-null section headers +/// for `[.dynstr, .dynamic, .gnu.version_r, .dynsym, .gnu.version]`, then +/// `.shstrtab` at [`SHSTRTAB_INDEX`], then the `debug` sections. +fn section_table( + body: &mut Writer, + ranges: [Range; 5], + requirements: u32, + debug: &[(&str, u32, Range)], +) -> Vec
{ + let mut names = Strings::new(); + let debug_names: Vec = debug.iter().map(|(name, _, _)| names.add(name)).collect(); + let [dynstr, dynamic, verneed, symbols, versions] = ranges; + let mut table = vec![ + Section { + name: names.add(".dynstr"), + kind: elf_format::SHT_STRTAB, + range: dynstr, + link: 0, + info: 0, + entry_size: 0, + }, + Section { + name: names.add(".dynamic"), + kind: elf_format::SHT_DYNAMIC, + range: dynamic, + link: DYNSTR_INDEX, + info: 0, + entry_size: 16, + }, + Section { + name: names.add(".gnu.version_r"), + kind: elf_format::SHT_GNU_VERNEED, + range: verneed, + link: DYNSTR_INDEX, + info: requirements, + entry_size: 0, + }, + Section { + name: names.add(".dynsym"), + kind: elf_format::SHT_DYNSYM, + range: symbols, + link: DYNSTR_INDEX, + info: 1, + entry_size: 24, + }, + Section { + name: names.add(".gnu.version"), + kind: elf_format::SHT_GNU_VERSYM, + range: versions, + link: 4, + info: 0, + entry_size: 2, + }, + ]; + let name = names.add(".shstrtab"); + let range = body.append(&names.0); + body.align(8); + table.push(Section { + name, + kind: elf_format::SHT_STRTAB, + range, + link: 0, + info: 0, + entry_size: 0, + }); + for (name, (_, kind, range)) in debug_names.into_iter().zip(debug) { + table.push(Section { + name, + kind: *kind, + range: *range, + link: 0, + info: 0, + entry_size: 0, + }); + } + table +} + +/// Encodes `.gnu.version_r` entries against `strings`. +fn verneed(requirements: &[(&str, Vec<&str>)], strings: &mut Strings) -> Vec { + let mut out = Writer::default(); + let mut version_index = 2; + for (index, (file, names)) in requirements.iter().enumerate() { + let last_need = index + 1 == requirements.len(); + let need_size = 16 + 16 * names.len(); + out.u16(1); + out.u16(u16::try_from(names.len()).expect("should fit")); + out.u32(strings.add(file)); + out.u32(16); + out.u32(if last_need { + 0 + } else { + u32::try_from(need_size).expect("should fit") + }); + for (aux, name) in names.iter().enumerate() { + out.u32(0); + out.u16(0); + out.u16(version_index); + version_index += 1; + out.u32(strings.add(name)); + out.u32(if aux + 1 == names.len() { 0 } else { 16 }); + } + } + out.0 +} + +fn write_elf_header( + out: &mut Writer, + machine: u16, + segments: u64, + section_offset: u64, + table: &[Section], +) { + out.0.extend_from_slice(&elf_format::ELFMAG); + out.0.extend_from_slice(&[ + elf_format::ELFCLASS64, + elf_format::ELFDATA2LSB, + elf_format::EV_CURRENT, + ]); + out.0.resize(16, 0); + out.u16(elf_format::ET_DYN); + out.u16(machine); + out.u32(u32::from(elf_format::EV_CURRENT)); + out.u64(0); + out.u64(ELF_HEADER_SIZE); + out.u64(section_offset); + out.u32(0); + out.u16(64); + out.u16(56); + out.u16(u16::try_from(segments).expect("should fit")); + out.u16(64); + out.u16(u16::try_from(table.len() + 1).expect("should fit")); + out.u16(SHSTRTAB_INDEX); +} + +fn program_header(out: &mut Writer, kind: u32, (offset, size): Range) { + out.u32(kind); + out.u32(elf_format::PF_R); + out.u64(offset); + out.u64(offset); + out.u64(offset); + out.u64(size); + out.u64(size); + out.u64(8); +} + +fn write_sections(out: &mut Writer, table: &[Section]) { + out.0.extend_from_slice(&[0; 64]); + for section in table { + out.u32(section.name); + out.u32(section.kind); + out.u64(0); + out.u64(section.range.0); + out.u64(section.range.0); + out.u64(section.range.1); + out.u32(section.link); + out.u32(section.info); + out.u64(1); + out.u64(section.entry_size); + } +} + +/// One synthetic Mach-O symbol table entry. +#[derive(Clone)] +pub(crate) struct Symbol { + pub(crate) n_type: u8, + pub(crate) name: &'static str, + pub(crate) value: u64, +} + +/// One synthetic Mach-O load command. +#[derive(Clone)] +pub(crate) enum Command { + BuildVersion { + platform: u32, + minos: u32, + }, + BuildVersionSdk { + platform: u32, + minos: u32, + sdk: u32, + }, + VersionMin { + version: u32, + }, + Dylinker(&'static str), + Dylib { + cmd: u32, + name: &'static str, + }, + Rpath(&'static str), + Uuid([u8; 16]), + /// `LC_SYMTAB` whose entries and strings follow the payload. + Symtab(Vec), + /// `LC_CODE_SIGNATURE` whose blob follows the symbol data. + CodeSignature(Vec), +} + +/// Parameters of a synthetic thin 64-bit Mach-O executable. +pub(crate) struct MachoSpec { + pub(crate) cputype: u32, + pub(crate) cpusubtype: u32, + pub(crate) commands: Vec, + pub(crate) payload: &'static [u8], +} + +/// Encodes `major.minor` in the Mach-O nibble form. +pub(crate) const fn macos(major: u32, minor: u32) -> u32 { + (major << 16) | (minor << 8) +} + +/// The SDK version every synthetic `LC_BUILD_VERSION` records unless a +/// [`Command::BuildVersionSdk`] states another. +pub(crate) const SYNTHETIC_SDK: (u32, u32) = (15, 5); + +impl MachoSpec { + /// A conforming arm64 `ts`-like executable targeting macOS 13.0, linker + /// signed, with a UUID and a relative zero-time debug map. + pub(crate) fn conforming() -> Self { + Self { + cputype: macho_format::CPU_TYPE_ARM64, + cpusubtype: macho_format::CPU_SUBTYPE_ARM64_ALL, + commands: vec![ + Command::BuildVersion { + platform: macho_format::PLATFORM_MACOS, + minos: macos(13, 0), + }, + Command::Dylinker("/usr/lib/dyld"), + Command::Dylib { + cmd: macho_format::LC_LOAD_DYLIB, + name: "/System/Library/Frameworks/Security.framework/Versions/A/Security", + }, + Command::Dylib { + cmd: macho_format::LC_LOAD_DYLIB, + name: "/usr/lib/libSystem.B.dylib", + }, + Command::Uuid([0x5a; 16]), + Command::Symtab(debug_map_symbols()), + Command::CodeSignature(linker_signature()), + ], + payload: b"fictional cstring /opt/example/src/main.rs", + } + } + + /// Returns the index of the first command `matches` selects. + pub(crate) fn position(&self, matches: impl Fn(&Command) -> bool) -> usize { + self.commands + .iter() + .position(matches) + .expect("should hold the command") + } +} + +/// Encoded size of one load command. +fn command_size(command: &Command) -> usize { + match command { + Command::BuildVersion { .. } | Command::BuildVersionSdk { .. } | Command::Uuid(_) => 24, + Command::VersionMin { .. } | Command::CodeSignature(_) => 16, + Command::Symtab(_) => 24, + Command::Dylinker(text) | Command::Rpath(text) => (12 + text.len() + 1).div_ceil(8) * 8, + Command::Dylib { name, .. } => (24 + name.len() + 1).div_ceil(8) * 8, + } +} + +/// Builds a synthetic thin Mach-O executable from `spec`. +/// +/// Symbol and signature data follow the payload, the signature last, so a +/// truncated image always cuts referenced data. +pub(crate) fn macho(spec: &MachoSpec) -> Vec { + let commands_size: usize = spec.commands.iter().map(command_size).sum(); + // Symbol entries need four-byte alignment, as in a real `__LINKEDIT`. + let base = (32 + commands_size + spec.payload.len()).div_ceil(8) * 8; + let mut data = Writer::default(); + let mut tables = Vec::new(); + for command in &spec.commands { + if let Command::Symtab(symbols) = command { + tables.push(symbol_data(symbols, &mut data, base)); + } + } + let mut layout = Layout { + tables: tables.into_iter(), + next_signature: base + data.0.len(), + }; + let mut commands = Writer::default(); + let mut signatures = Writer::default(); + for command in &spec.commands { + encode_command(&mut commands, command, &mut layout); + if let Command::CodeSignature(blob) = command { + signatures.0.extend_from_slice(blob); + } + } + let mut out = Writer::default(); + out.u32(macho_format::MH_MAGIC_64); + out.u32(spec.cputype); + out.u32(spec.cpusubtype); + out.u32(macho_format::MH_EXECUTE); + out.u32(u32::try_from(spec.commands.len()).expect("should fit")); + out.u32(u32::try_from(commands.0.len()).expect("should fit")); + out.u32(0); + out.u32(0); + out.0.extend_from_slice(&commands.0); + out.0.extend_from_slice(spec.payload); + if !data.0.is_empty() || !signatures.0.is_empty() { + out.align(8); + } + out.0.extend_from_slice(&data.0); + out.0.extend_from_slice(&signatures.0); + out.0 +} + +/// Appends a symbol table's entries and strings to `data`; returns the +/// `(symoff, nsyms, stroff, strsize)` fields. +fn symbol_data(symbols: &[Symbol], data: &mut Writer, base: usize) -> [u32; 4] { + let mut strings = Strings::new(); + let mut entries = Writer::default(); + for symbol in symbols { + entries.u32(strings.add(symbol.name)); + entries.0.push(symbol.n_type); + entries.0.push(1); + entries.u16(0); + entries.u64(symbol.value); + } + let field = |value: usize| u32::try_from(value).expect("should fit"); + let symoff = base + data.0.len(); + data.0.extend_from_slice(&entries.0); + let stroff = base + data.0.len(); + data.0.extend_from_slice(&strings.0); + [ + field(symoff), + field(symbols.len()), + field(stroff), + field(strings.0.len()), + ] +} + +/// Precomputed data placement for the commands that reference file data. +struct Layout { + tables: std::vec::IntoIter<[u32; 4]>, + next_signature: usize, +} + +fn encode_command(out: &mut Writer, command: &Command, layout: &mut Layout) { + match command { + Command::BuildVersion { platform, minos } => { + let (major, minor) = SYNTHETIC_SDK; + encode_command( + out, + &Command::BuildVersionSdk { + platform: *platform, + minos: *minos, + sdk: macos(major, minor), + }, + layout, + ); + } + Command::BuildVersionSdk { + platform, + minos, + sdk, + } => { + out.u32(macho_format::LC_BUILD_VERSION); + out.u32(24); + out.u32(*platform); + out.u32(*minos); + out.u32(*sdk); + out.u32(0); + } + Command::Uuid(uuid) => { + out.u32(macho_format::LC_UUID); + out.u32(24); + out.0.extend_from_slice(uuid); + } + Command::Symtab(_) => { + out.u32(macho_format::LC_SYMTAB); + out.u32(24); + for field in layout + .tables + .next() + .expect("should hold the table placement") + { + out.u32(field); + } + } + Command::CodeSignature(blob) => { + out.u32(macho_format::LC_CODE_SIGNATURE); + out.u32(16); + out.u32(u32::try_from(layout.next_signature).expect("should fit")); + out.u32(u32::try_from(blob.len()).expect("should fit")); + layout.next_signature += blob.len(); + } + Command::VersionMin { version } => { + out.u32(macho_format::LC_VERSION_MIN_MACOSX); + out.u32(16); + out.u32(*version); + out.u32(macos(15, 5)); + } + Command::Dylinker(name) => string_command(out, macho_format::LC_LOAD_DYLINKER, &[], name), + Command::Dylib { cmd, name } => { + string_command(out, *cmd, &[2, 0x0001_0000, 0x0001_0000], name); + } + Command::Rpath(path) => string_command(out, macho_format::LC_RPATH, &[], path), + } +} + +/// Encodes a command whose string follows `fields` after the name offset. +fn string_command(out: &mut Writer, cmd: u32, fields: &[u32], text: &str) { + let fixed = 12 + 4 * fields.len(); + let size = (fixed + text.len() + 1).div_ceil(8) * 8; + out.u32(cmd); + out.u32(u32::try_from(size).expect("should fit")); + out.u32(u32::try_from(fixed).expect("should fit")); + for field in fields { + out.u32(*field); + } + out.0.extend_from_slice(text.as_bytes()); + out.0.resize(out.0.len() + size - fixed - text.len(), 0); +} + +/// Builds a two-architecture fat header around `thin`. +pub(super) fn fat(thin: &[u8]) -> Vec { + let mut out = Vec::new(); + for value in [ + macho_format::FAT_MAGIC, + 1, + macho_format::CPU_TYPE_ARM64, + 0, + 4096, + ] { + out.extend_from_slice(&value.to_be_bytes()); + } + for value in [u32::try_from(thin.len()).expect("should fit"), 12] { + out.extend_from_slice(&value.to_be_bytes()); + } + out.resize(4096, 0); + out.extend_from_slice(thin); + out +} + +/// Builds a core Wasm module importing `() -> ()` functions. +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/builders/macho_data.rs b/crates/trusted-server-release/src/inspect/tests/builders/macho_data.rs new file mode 100644 index 000000000..6ce4ef24d --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/builders/macho_data.rs @@ -0,0 +1,72 @@ +//! Synthetic Mach-O debug-map symbols and code signature blobs. + +use object::macho as macho_format; + +use super::Symbol; + +/// Fictional relative debug-map entries of a linked `ts`. +pub(crate) fn debug_map_symbols() -> Vec { + vec![ + Symbol { + n_type: macho_format::N_OSO, + name: "cargo-target/aarch64-apple-darwin/release/deps/ts-0123456789abcdef.o", + value: 0, + }, + Symbol { + n_type: macho_format::N_OSO, + name: "rust-tools/lib/rustlib/aarch64-apple-darwin/lib/libstd-0123.rlib(std.o)", + value: 0, + }, + Symbol { + n_type: macho_format::N_SECT | macho_format::N_EXT, + name: "_main", + value: 0x1_0000_0000, + }, + ] +} + +/// A big-endian `SuperBlob` with one `CodeDirectory` carrying `flags`, an +/// optional team identifier offset and `extra_slots` index entries. +pub(crate) fn code_signature(flags: u32, team_offset: u32, extra_slots: &[u32]) -> Vec { + let count = 1 + extra_slots.len(); + let header = 12 + 8 * count; + let mut directory = Vec::new(); + for value in [0xfade_0c02_u32, 88, 0x2_0400, flags, 0, 0, 0, 0, 0] { + directory.extend_from_slice(&value.to_be_bytes()); + } + directory.extend_from_slice(&[32, 2, 0, 12]); + for value in [0_u32, 0, team_offset] { + directory.extend_from_slice(&value.to_be_bytes()); + } + directory.resize(88, 0); + let mut extra = Vec::new(); + let mut entries = vec![(0_u32, header)]; + for slot in extra_slots { + entries.push((*slot, header + directory.len() + extra.len())); + for value in [0xfade_0c01_u32, 12, 0] { + extra.extend_from_slice(&value.to_be_bytes()); + } + } + let length = header + directory.len() + extra.len(); + let mut out = Vec::new(); + for value in [ + 0xfade_0cc0_u32, + u32::try_from(length).expect("should fit"), + u32::try_from(count).expect("should fit"), + ] { + out.extend_from_slice(&value.to_be_bytes()); + } + for (slot, offset) in entries { + out.extend_from_slice(&slot.to_be_bytes()); + out.extend_from_slice(&u32::try_from(offset).expect("should fit").to_be_bytes()); + } + out.extend_from_slice(&directory); + out.extend_from_slice(&extra); + out +} + +/// The linker's ad-hoc signature: flags `0x20002`, no team identifier and an +/// empty requirements slot. +pub(crate) fn linker_signature() -> Vec { + code_signature(0x2_0002, 0, &[2]) +} diff --git a/crates/trusted-server-release/src/inspect/tests/elf.rs b/crates/trusted-server-release/src/inspect/tests/elf.rs new file mode 100644 index 000000000..81548c77a --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/elf.rs @@ -0,0 +1,429 @@ +//! ELF inspection tests. + +use object::elf as elf_format; + +use super::builders::{ElfSpec, ExtraSection, MachoSpec, elf, macho}; +use super::*; + +fn inspect(spec: &ElfSpec) -> Result> { + inspect_elf(&elf(spec), &ElfPolicy::linux_x86_64_gnu()) +} + +#[test] +fn conforming_executable_is_accepted_with_observed_facts() { + let bytes = elf(&ElfSpec::conforming()); + + let facts = inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()) + .expect("should accept a conforming executable"); + + assert_eq!(facts.interpreter(), "/lib64/ld-linux-x86-64.so.2"); + assert_eq!(facts.needed(), ["libgcc_s.so.1", "libm.so.6", "libc.so.6"]); + assert_eq!( + facts.max_glibc(), + GlibcVersion::new(2, 34), + "should record the highest GLIBC requirement numerically" + ); + assert_eq!( + facts.version_requirements().len(), + 5, + "should record every requirement" + ); + assert_eq!( + facts.sha256(), + sha256_hex(&bytes), + "should hash the inspected bytes" + ); +} + +#[test] +fn glibc_above_ceiling_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.requirements[1].1.push("GLIBC_2.35"); + + assert_eq!(violation(inspect(&spec)), Violation::SymbolVersion); +} + +#[test] +fn unparsable_glibc_version_is_rejected() { + for name in ["GLIBC_PRIVATE", "GLIBC_ABI_DT_RELR"] { + let mut spec = ElfSpec::conforming(); + spec.requirements[1].1.push(name); + + assert_eq!( + violation(inspect(&spec)), + Violation::SymbolVersion, + "should reject {name}" + ); + } +} + +#[test] +fn unexpected_version_namespace_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.requirements[1].1.push("GCC_3.0"); + + assert_eq!( + violation(inspect(&spec)), + Violation::SymbolVersion, + "should accept GCC_ names only from libgcc_s" + ); +} + +#[test] +fn missing_glibc_requirements_are_rejected() { + let mut spec = ElfSpec::conforming(); + spec.requirements.truncate(1); + + assert_eq!(violation(inspect(&spec)), Violation::SymbolVersion); +} + +#[test] +fn wrong_architecture_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.machine = elf_format::EM_AARCH64; + + assert_eq!(violation(inspect(&spec)), Violation::Architecture); +} + +#[test] +fn missing_interpreter_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.interpreter = None; + + assert_eq!(violation(inspect(&spec)), Violation::Interpreter); +} + +#[test] +fn unexpected_interpreter_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.interpreter = Some("/opt/example/ld.so"); + + assert_eq!(violation(inspect(&spec)), Violation::Interpreter); +} + +#[test] +fn unexpected_needed_library_is_rejected() { + for name in ["libssl.so.3", "libpthread.so.0", "/opt/example/libc.so.6"] { + let mut spec = ElfSpec::conforming(); + spec.needed.push(name); + + assert_eq!( + violation(inspect(&spec)), + Violation::Library, + "should reject {name}" + ); + } +} + +#[test] +fn runpath_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.runpath = Some("$ORIGIN/../lib"); + + assert_eq!(violation(inspect(&spec)), Violation::SearchPath); +} + +fn replace_dynamic_terminator(bytes: &mut [u8], tag: u32, value: u64) { + let section_table = u64::from_le_bytes(bytes[40..48].try_into().expect("should read offset")); + let section = usize::try_from(section_table).expect("should fit") + 2 * 64; + let offset = u64::from_le_bytes( + bytes[section + 24..section + 32] + .try_into() + .expect("should read offset"), + ); + let size = u64::from_le_bytes( + bytes[section + 32..section + 40] + .try_into() + .expect("should read size"), + ); + let start = usize::try_from(offset).expect("should fit"); + let end = usize::try_from(offset + size).expect("should fit"); + let entry = (start..end) + .step_by(16) + .find(|entry| read_u64(bytes, *entry) == 0) + .expect("should find terminator"); + bytes[entry..entry + 8].copy_from_slice(&u64::from(tag).to_le_bytes()); + bytes[entry + 8..entry + 16].copy_from_slice(&value.to_le_bytes()); +} + +#[test] +fn loader_auditors_are_rejected() { + for tag in [elf_format::DT_AUDIT, elf_format::DT_DEPAUDIT] { + let mut bytes = elf(&ElfSpec::conforming()); + replace_dynamic_terminator(&mut bytes, tag, 0); + assert_eq!( + violation(inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu())), + Violation::Library, + "should reject loader auditor tags" + ); + } +} + +#[test] +fn unterminated_dynamic_table_is_rejected() { + let mut bytes = elf(&ElfSpec::conforming()); + replace_dynamic_terminator(&mut bytes, elf_format::DT_DEBUG, 0); + replace_dynamic_terminator(&mut bytes, elf_format::DT_DEBUG, 0); + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should require a dynamic table terminator" + ); +} + +fn read_u64(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes( + bytes[offset..offset + 8] + .try_into() + .expect("should read field"), + ) +} + +fn section_offset(bytes: &[u8], index: usize) -> usize { + usize::try_from(read_u64(bytes, 40)).expect("should fit section offset") + index * 64 +} + +fn change_dynamic_value(bytes: &mut [u8], tag: u32) { + let section = section_offset(bytes, 2); + let start = usize::try_from(read_u64(bytes, section + 24)).expect("should fit"); + let end = start + usize::try_from(read_u64(bytes, section + 32)).expect("should fit"); + let entry = (start..end) + .step_by(16) + .find(|offset| read_u64(bytes, *offset) == u64::from(tag)) + .expect("should find dynamic tag"); + let changed = read_u64(bytes, entry + 8) + 1; + bytes[entry + 8..entry + 16].copy_from_slice(&changed.to_le_bytes()); +} + +#[test] +fn loader_metadata_must_match_all_observed_sections() { + for tag in [ + elf_format::DT_STRSZ, + elf_format::DT_VERNEED, + elf_format::DT_VERNEEDNUM, + elf_format::DT_SYMTAB, + elf_format::DT_SYMENT, + elf_format::DT_VERSYM, + ] { + let mut bytes = elf(&ElfSpec::conforming()); + change_dynamic_value(&mut bytes, tag); + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should reject section/dynamic disagreement for tag {tag}" + ); + } +} + +#[test] +fn shadow_string_table_cannot_hide_loader_libraries() { + let mut bytes = elf(&ElfSpec::conforming()); + let section = section_offset(&bytes, 1); + let start = usize::try_from(read_u64(&bytes, section + 24)).expect("should fit"); + let size = usize::try_from(read_u64(&bytes, section + 32)).expect("should fit"); + let shadow = bytes[start..start + size].to_vec(); + let shadow_offset = bytes.len() as u64; + bytes.extend_from_slice(&shadow); + bytes[section + 24..section + 32].copy_from_slice(&shadow_offset.to_le_bytes()); + let name = bytes[start..start + size] + .windows(9) + .position(|name| name == b"libc.so.6") + .expect("should find real loader library"); + bytes[start + name..start + name + 9].copy_from_slice(b"evil.so.6"); + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should inspect the loader's actual strings rather than a shadow section" + ); +} + +#[test] +fn undefined_symbol_cannot_hide_an_unaccounted_version() { + let mut bytes = elf(&ElfSpec::conforming()); + let section = section_offset(&bytes, 5); + let start = usize::try_from(read_u64(&bytes, section + 24)).expect("should fit"); + bytes[start + 2..start + 4].copy_from_slice(&999_u16.to_le_bytes()); + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should bind undefined symbol versions to checked requirements" + ); +} + +#[test] +fn partially_overlapping_loads_cannot_replace_observed_bytes() { + for file_size in [0_u64, 1] { + let mut bytes = elf(&ElfSpec::conforming()); + let strings = section_offset(&bytes, 1); + let address = read_u64(&bytes, strings + 16) + 4; + let note = (64..64 + 4 * 56) + .step_by(56) + .find(|offset| bytes[*offset..*offset + 4] == elf_format::PT_NOTE.to_le_bytes()) + .expect("should find spare program header"); + bytes[note..note + 4].copy_from_slice(&elf_format::PT_LOAD.to_le_bytes()); + for (field, value) in [(8, address), (16, address), (32, file_size), (40, 1)] { + bytes[note + field..note + field + 8].copy_from_slice(&value.to_le_bytes()); + } + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should reject partial file-backed and zero-fill overlaps" + ); + } +} + +#[test] +fn embedded_runner_path_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.payload = b"panic at /home/runner/work/example/src/lib.rs"; + + assert_eq!(violation(inspect(&spec)), Violation::EmbeddedPath); +} + +#[test] +fn denied_prefixes_are_configurable() { + let policy = ElfPolicy { + denied_path_prefixes: &["/opt/example/"], + ..ElfPolicy::linux_x86_64_gnu() + }; + + let result = inspect_elf(&elf(&ElfSpec::conforming()), &policy); + + assert_eq!(violation(result), Violation::EmbeddedPath); +} + +#[test] +fn non_elf_inputs_are_rejected() { + let policy = ElfPolicy::linux_x86_64_gnu(); + let thin = macho(&MachoSpec::conforming()); + + assert_eq!(violation(inspect_elf(&thin, &policy)), Violation::Malformed); + assert_eq!( + violation(inspect_elf(b"\0asm\x01\0\0\0", &policy)), + Violation::Malformed + ); +} + +#[test] +fn truncated_executables_are_rejected_without_panic() { + let policy = ElfPolicy::linux_x86_64_gnu(); + + assert_truncations_rejected(&elf(&ElfSpec::conforming()), |bytes| { + inspect_elf(bytes, &policy) + }); +} + +#[test] +fn retained_debug_sections_are_recorded_and_required() { + let facts = inspect(&ElfSpec::conforming()).expect("should accept a conforming executable"); + + let recorded: Vec<(&str, usize)> = facts + .debug_sections() + .iter() + .map(|section| (section.name(), section.size())) + .collect(); + assert_eq!( + recorded, + [(".debug_info", 20), (".debug_line", 21)], + "should record each required DWARF section with its file bytes" + ); + facts + .require_debug_sections() + .expect("should accept an executable that kept its debug info"); +} + +#[test] +fn stripped_or_empty_debug_sections_are_rejected() { + let cases: [(&str, Vec); 4] = [ + ("stripped", Vec::new()), + ( + "missing line table", + vec![(".debug_info", elf_format::SHT_PROGBITS, b"fictional dwarf")], + ), + ( + "empty info", + vec![ + (".debug_info", elf_format::SHT_PROGBITS, b""), + (".debug_line", elf_format::SHT_PROGBITS, b"fictional dwarf"), + ], + ), + ( + "no file bytes", + vec![ + (".debug_info", elf_format::SHT_PROGBITS, b"fictional dwarf"), + (".debug_line", elf_format::SHT_NOBITS, b"fictional dwarf"), + ], + ), + ]; + for (label, debug_sections) in cases { + let spec = ElfSpec { + debug_sections, + ..ElfSpec::conforming() + }; + let facts = inspect(&spec).expect("should record, not reject, missing debug info"); + + assert_eq!( + violation(facts.require_debug_sections()), + Violation::ElfDebugInfo, + "should reject the {label} executable" + ); + } +} + +#[test] +fn runner_path_rejection_includes_final_elf_diagnostics() { + let mut spec = ElfSpec::conforming(); + spec.debug_sections.push(( + ".rodata", + elf_format::SHT_PROGBITS, + b"before /home/runner/example\n\x1bafter", + )); + let bytes = elf(&spec); + let report = inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()) + .expect_err("should retain rejection of a runner-only path"); + assert_eq!( + report.downcast_ref::(), + Some(&Violation::EmbeddedPath), + "should preserve the rejection rule" + ); + let diagnostic = format!("{report:?}"); + assert!( + diagnostic.contains(&format!( + "artifact sha256={} size={}", + sha256_hex(&bytes), + bytes.len() + )), + "should bind diagnostic to the final bytes: {diagnostic}" + ); + assert!( + diagnostic.contains("section=\".rodata\""), + "should identify the containing ELF section: {diagnostic}" + ); + assert!( + diagnostic.contains("/home/runner/example\\n\\x1bafter"), + "should escape controls in a bounded context: {diagnostic}" + ); +} + +#[test] +fn linux_runtime_browser_lookup_path_is_structurally_accepted() { + let mut spec = ElfSpec::conforming(); + spec.debug_sections.push(( + ".rodata", + elf_format::SHT_PROGBITS, + b"/opt/google/chrome/tmp/aws/libCHROME", + )); + inspect(&spec).expect("should permit a Linux runtime temporary-directory lookup constant"); +} + +#[test] +fn linux_keeps_every_other_runner_path_prefix_rejected() { + for prefix in RUNNER_PATH_PREFIXES + .iter() + .copied() + .filter(|prefix| *prefix != "/tmp/") + { + let mut spec = ElfSpec::conforming(); + spec.payload = prefix.as_bytes(); + assert_eq!( + violation(inspect(&spec)), + Violation::EmbeddedPath, + "should keep rejecting runner-only prefix {prefix}" + ); + } +} diff --git a/crates/trusted-server-release/src/inspect/tests/macho.rs b/crates/trusted-server-release/src/inspect/tests/macho.rs new file mode 100644 index 000000000..90487f55a --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/macho.rs @@ -0,0 +1,480 @@ +//! Mach-O inspection tests. + +use object::macho as macho_format; + +use super::builders::{ + Command, ElfSpec, MachoSpec, Symbol, code_signature, debug_map_symbols, elf, fat, + linker_signature, macho, macos, +}; +use super::*; + +fn inspect(spec: &MachoSpec) -> Result> { + inspect_macho(&macho(spec), &MachoPolicy::macos(MachoCpu::Arm64)) +} + +fn with_command(command: Command) -> MachoSpec { + let mut spec = MachoSpec::conforming(); + spec.commands.push(command); + spec +} + +#[test] +fn conforming_executable_is_accepted_with_observed_facts() { + let bytes = macho(&MachoSpec::conforming()); + + let facts = inspect_macho(&bytes, &MachoPolicy::macos(MachoCpu::Arm64)) + .expect("should accept a conforming executable"); + + assert_eq!(facts.cpu(), MachoCpu::Arm64); + assert_eq!( + facts.loader(), + "/usr/lib/dyld", + "should retain the observed and validated dynamic linker" + ); + assert_eq!( + facts.minos(), + MacosVersion::new(13, 0), + "should record minos 13.0" + ); + assert_eq!( + facts.sdk(), + MacosVersion::new(15, 5), + "should record the SDK version" + ); + assert_eq!( + facts.dylibs(), + [ + "/System/Library/Frameworks/Security.framework/Versions/A/Security", + "/usr/lib/libSystem.B.dylib", + ] + ); + assert_eq!( + facts.sha256(), + sha256_hex(&bytes), + "should hash the inspected bytes" + ); +} + +#[test] +fn x86_64_policy_accepts_x86_64_executable() { + let mut spec = MachoSpec::conforming(); + spec.cputype = macho_format::CPU_TYPE_X86_64; + spec.cpusubtype = macho_format::CPU_SUBTYPE_X86_64_ALL | macho_format::CPU_SUBTYPE_LIB64; + + let facts = inspect_macho(&macho(&spec), &MachoPolicy::macos(MachoCpu::X86_64)) + .expect("should accept x86_64 with capability bits masked"); + + assert_eq!(facts.cpu(), MachoCpu::X86_64); +} + +#[test] +fn deployment_target_other_than_floor_is_rejected() { + for (major, minor) in [(11, 0), (14, 0), (12, 7)] { + let mut spec = MachoSpec::conforming(); + spec.commands[0] = Command::BuildVersion { + platform: macho_format::PLATFORM_MACOS, + minos: macos(major, minor), + }; + + assert_eq!( + violation(inspect(&spec)), + Violation::DeploymentTarget, + "should reject minos {major}.{minor}" + ); + } +} + +#[test] +fn missing_or_legacy_deployment_target_is_rejected() { + let mut missing = MachoSpec::conforming(); + missing.commands.remove(0); + let mut legacy = MachoSpec::conforming(); + legacy.commands[0] = Command::VersionMin { + version: macos(13, 0), + }; + let duplicate = with_command(Command::BuildVersion { + platform: macho_format::PLATFORM_MACOS, + minos: macos(13, 0), + }); + + for spec in [missing, legacy, duplicate] { + assert_eq!(violation(inspect(&spec)), Violation::DeploymentTarget); + } +} + +#[test] +fn non_macos_platform_is_rejected() { + let mut spec = MachoSpec::conforming(); + spec.commands[0] = Command::BuildVersion { + platform: macho_format::PLATFORM_IOS, + minos: macos(13, 0), + }; + + assert_eq!(violation(inspect(&spec)), Violation::DeploymentTarget); +} + +#[test] +fn wrong_cpu_is_rejected() { + let mut x86 = MachoSpec::conforming(); + x86.cputype = macho_format::CPU_TYPE_X86_64; + x86.cpusubtype = macho_format::CPU_SUBTYPE_X86_64_ALL; + let mut arm64e = MachoSpec::conforming(); + arm64e.cpusubtype = macho_format::CPU_SUBTYPE_ARM64E; + + for spec in [x86, arm64e] { + assert_eq!(violation(inspect(&spec)), Violation::Architecture); + } +} + +#[test] +fn fat_binary_is_rejected() { + let thin = macho(&MachoSpec::conforming()); + + let result = inspect_macho(&fat(&thin), &MachoPolicy::macos(MachoCpu::Arm64)); + + assert_eq!(violation(result), Violation::Architecture); +} + +#[test] +fn unexpected_dylib_is_rejected() { + let spec = with_command(Command::Dylib { + cmd: macho_format::LC_LOAD_DYLIB, + name: "/usr/local/lib/libexample.dylib", + }); + + assert_eq!(violation(inspect(&spec)), Violation::Library); +} + +#[test] +fn non_plain_dylib_loads_are_rejected() { + for cmd in [ + macho_format::LC_LOAD_WEAK_DYLIB, + macho_format::LC_REEXPORT_DYLIB, + macho_format::LC_LAZY_LOAD_DYLIB, + macho_format::LC_LOAD_UPWARD_DYLIB, + ] { + let spec = with_command(Command::Dylib { + cmd, + name: "/usr/lib/libSystem.B.dylib", + }); + + assert_eq!( + violation(inspect(&spec)), + Violation::LinkKind, + "should reject {cmd:#x}" + ); + } +} + +#[test] +fn rpath_is_rejected() { + let spec = with_command(Command::Rpath("@executable_path/../lib")); + + assert_eq!(violation(inspect(&spec)), Violation::SearchPath); +} + +#[test] +fn unexpected_dylinker_is_rejected() { + let mut spec = MachoSpec::conforming(); + spec.commands[1] = Command::Dylinker("/opt/example/dyld"); + + assert_eq!(violation(inspect(&spec)), Violation::Interpreter); +} + +#[test] +fn embedded_runner_path_is_rejected() { + let mut spec = MachoSpec::conforming(); + spec.payload = b"panic at /Users/runner/work/example/src/lib.rs"; + + assert_eq!(violation(inspect(&spec)), Violation::EmbeddedPath); +} + +#[test] +fn non_macho_inputs_are_rejected() { + let policy = MachoPolicy::macos(MachoCpu::Arm64); + let linux = elf(&ElfSpec::conforming()); + + assert_eq!( + violation(inspect_macho(&linux, &policy)), + Violation::Malformed + ); + assert_eq!( + violation(inspect_macho(b"not an executable", &policy)), + Violation::Malformed + ); +} + +#[test] +fn truncated_executables_are_rejected_without_panic() { + let policy = MachoPolicy::macos(MachoCpu::Arm64); + let mut spec = MachoSpec::conforming(); + spec.payload = b""; + + assert_truncations_rejected(&macho(&spec), |bytes| inspect_macho(bytes, &policy)); +} + +#[test] +fn macos_temporary_directory_paths_remain_rejected() { + let mut spec = MachoSpec::conforming(); + spec.payload = b"/tmp/example/generated.rs"; + assert_eq!( + violation(inspect(&spec)), + Violation::EmbeddedPath, + "should retain the macOS policy until its filesystem boundary is qualified" + ); +} + +fn signature_index(spec: &MachoSpec) -> usize { + spec.position(|command| matches!(command, Command::CodeSignature(_))) +} + +fn symtab_index(spec: &MachoSpec) -> usize { + spec.position(|command| matches!(command, Command::Symtab(_))) +} + +fn with_signature(blob: Vec) -> MachoSpec { + let mut spec = MachoSpec::conforming(); + let index = signature_index(&spec); + spec.commands[index] = Command::CodeSignature(blob); + spec +} + +fn with_symbols(symbols: Vec) -> MachoSpec { + let mut spec = MachoSpec::conforming(); + let index = symtab_index(&spec); + spec.commands[index] = Command::Symtab(symbols); + spec +} + +fn object(name: &'static str, value: u64) -> Symbol { + Symbol { + n_type: macho_format::N_OSO, + name, + value, + } +} + +#[test] +fn conforming_executable_records_uuid_debug_map_and_signature() { + let facts = inspect(&MachoSpec::conforming()).expect("should accept a conforming executable"); + + assert_eq!(facts.uuid(), [0x5a; 16], "should record LC_UUID"); + assert_eq!(facts.symbol_count(), 3, "should count every symbol"); + assert_eq!( + facts.debug_map_objects(), + 2, + "should count the relative debug-map objects" + ); + assert_eq!( + facts.signature(), + CodeSignature::LinkerSignedAdhoc, + "should record the linker's ad-hoc signature" + ); + facts + .require_debug_map() + .expect("should keep the debug map a release build needs"); +} + +#[test] +fn arm64_must_be_linker_signed_while_x86_64_may_be_unsigned() { + let mut unsigned_arm64 = MachoSpec::conforming(); + unsigned_arm64 + .commands + .remove(signature_index(&unsigned_arm64)); + assert_eq!( + violation(inspect(&unsigned_arm64)), + Violation::CodeSignature, + "should require a signature on arm64" + ); + + let mut unsigned_x86_64 = MachoSpec::conforming(); + unsigned_x86_64 + .commands + .remove(signature_index(&unsigned_x86_64)); + unsigned_x86_64.cputype = macho_format::CPU_TYPE_X86_64; + unsigned_x86_64.cpusubtype = macho_format::CPU_SUBTYPE_X86_64_ALL; + let facts = inspect_macho( + &macho(&unsigned_x86_64), + &MachoPolicy::macos(MachoCpu::X86_64), + ) + .expect("should accept an unsigned x86_64 executable"); + assert_eq!(facts.signature(), CodeSignature::Unsigned); +} + +#[test] +fn signatures_other_than_linker_signed_ad_hoc_are_rejected() { + let mut bad_magic = linker_signature(); + bad_magic[0] = 0; + let mut short_length = linker_signature(); + short_length[4..8].copy_from_slice(&4_u32.to_be_bytes()); + let mut long_length = linker_signature(); + long_length[4..8].copy_from_slice(&u32::MAX.to_be_bytes()); + let cases: [(&str, Vec); 9] = [ + ( + "ad hoc without linker signing", + code_signature(0x2, 0, &[2]), + ), + ( + "linker signing without ad hoc", + code_signature(0x2_0000, 0, &[2]), + ), + ("no flags", code_signature(0, 0, &[2])), + ("a team identifier", code_signature(0x2_0002, 52, &[2])), + ( + "a CMS signature blob", + code_signature(0x2_0002, 0, &[0x1_0000]), + ), + ("an entitlements slot", code_signature(0x2_0002, 0, &[5])), + ("another magic", bad_magic), + ("a length below the header", short_length), + ("a length past the blob", long_length), + ]; + for (label, blob) in cases { + assert_eq!( + violation(inspect(&with_signature(blob))), + Violation::CodeSignature, + "should reject a signature with {label}" + ); + } +} + +#[test] +fn duplicate_code_directories_or_signatures_are_rejected() { + let two_directories = with_signature(code_signature(0x2_0002, 0, &[0])); + assert_eq!( + violation(inspect(&two_directories)), + Violation::CodeSignature + ); + + let doubled = with_command(Command::CodeSignature(linker_signature())); + assert_eq!(violation(inspect(&doubled)), Violation::CodeSignature); +} + +#[test] +fn debug_map_objects_must_be_relative_and_zero_time() { + let cases: [(&str, Symbol); 4] = [ + ( + "an absolute object", + object("/opt/example/target/release/deps/ts.o", 0), + ), + ("an empty object path", object("", 0)), + ( + "a modification time", + object("cargo-target/release/deps/ts.o", 1_700_000_000), + ), + ( + "an absolute archive member", + object("/opt/example/rust-tools/libstd.rlib(std.o)", 0), + ), + ]; + for (label, symbol) in cases { + let mut symbols = debug_map_symbols(); + symbols.push(symbol); + assert_eq!( + violation(inspect(&with_symbols(symbols))), + Violation::MachoDebugMap, + "should reject {label}" + ); + } +} + +#[test] +fn stripped_executables_fail_the_debug_map_requirement() { + let mut no_symbols = MachoSpec::conforming(); + no_symbols.commands.remove(symtab_index(&no_symbols)); + let no_objects = with_symbols(vec![Symbol { + n_type: macho_format::N_SECT | macho_format::N_EXT, + name: "_main", + value: 0x1_0000_0000, + }]); + let empty = with_symbols(Vec::new()); + for (label, spec) in [ + ("without a symbol table", no_symbols), + ("without debug-map objects", no_objects), + ("with an empty symbol table", empty), + ] { + let facts = inspect(&spec).expect("should inspect the stripped executable"); + assert_eq!( + violation(facts.require_debug_map()), + Violation::MachoDebugMap, + "should reject an executable {label}" + ); + } + let doubled = with_command(Command::Symtab(debug_map_symbols())); + assert_eq!(violation(inspect(&doubled)), Violation::MachoDebugMap); +} + +#[test] +fn uuid_is_required_exactly_once() { + let mut missing = MachoSpec::conforming(); + missing + .commands + .remove(missing.position(|command| matches!(command, Command::Uuid(_)))); + assert_eq!( + violation(inspect(&missing)), + Violation::MachoIdentity, + "should reject a linked executable without LC_UUID" + ); + + let doubled = with_command(Command::Uuid([0x6b; 16])); + assert_eq!(violation(inspect(&doubled)), Violation::MachoIdentity); +} + +#[test] +fn requirements_must_be_one_empty_set() { + let mut populated = linker_signature(); + let count_at = populated.len() - 4; + populated[count_at..].copy_from_slice(&1_u32.to_be_bytes()); + let mut wrong_magic = linker_signature(); + let magic_at = wrong_magic.len() - 12; + wrong_magic[magic_at..magic_at + 4].copy_from_slice(&0xfade_0c05_u32.to_be_bytes()); + let cases: [(&str, Vec); 3] = [ + ("a populated requirements set", populated), + ("another requirements magic", wrong_magic), + ( + "two requirements blobs", + code_signature(0x2_0002, 0, &[2, 2]), + ), + ]; + for (label, blob) in cases { + assert_eq!( + violation(inspect(&with_signature(blob))), + Violation::CodeSignature, + "should reject {label}" + ); + } + inspect(&with_signature(code_signature(0x2_0002, 0, &[]))) + .expect("should accept a signature without a requirements blob"); +} + +#[test] +fn build_version_records_the_linked_sdk() { + let mut spec = MachoSpec::conforming(); + spec.commands[0] = Command::BuildVersionSdk { + platform: macho_format::PLATFORM_MACOS, + minos: macos(13, 0), + sdk: macos(26, 1), + }; + + let facts = inspect(&spec).expect("should accept another SDK version"); + + assert_eq!( + facts.sdk(), + MacosVersion::new(26, 1), + "should record the SDK" + ); +} + +#[test] +fn sdk_versions_parse_only_dotted_decimal_text() { + assert_eq!(MacosVersion::parse("15.5"), Some(MacosVersion::new(15, 5))); + assert_eq!( + MacosVersion::parse("26.0.1").map(|version| version.to_string()), + Some("26.0.1".to_owned()) + ); + for text in [ + "", "15", "15.", "15.5.1.2", "015.5", "15.05", "+15.5", "15.256", "a.b", + ] { + assert_eq!(MacosVersion::parse(text), None, "should reject {text:?}"); + } +} 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 000000000..f1f843250 --- /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::WasmImport, + "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::WasmImport, + "should reject non-function imports from {module}" + ); + } +} + +#[test] +fn component_is_rejected() { + assert_eq!( + violation(inspect_wasm(b"\0asm\x0d\0\x01\0")), + Violation::WasmEncoding, + "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::WasmEncoding); + } +} + +#[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::WasmValidation); + } +} + +/// 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::WasmDebugInfo, + "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::WasmAbi, + "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::WasmImport, + "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 000000000..23b77e478 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/wasm.rs @@ -0,0 +1,159 @@ +//! 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::WasmEncoding`]), fail +/// validation under [`WasmFeatures::WASM2`] ([`Violation::WasmValidation`]), +/// or import a non-function item or from a module outside +/// [`FASTLY_IMPORT_MODULES`] ([`Violation::WasmImport`]). +pub(crate) fn inspect_wasm(bytes: &[u8]) -> Result> { + if Parser::is_component(bytes) { + return Err(rejected( + Violation::WasmEncoding, + "component, not a core module", + )); + } + if !Parser::is_core_wasm(bytes) { + return Err(rejected( + Violation::WasmEncoding, + "missing core module header", + )); + } + let types = Validator::new_with_features(FEATURES) + .validate_all(bytes) + .map_err(|error| rejected(Violation::WasmValidation, 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::WasmImport, + 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::WasmEncoding, + "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::WasmValidation, 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::WasmValidation, 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::WasmImport, + 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 index 44eac5a2a..0b0ec7aeb 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -4,6 +4,8 @@ mod documents; mod error; #[allow(dead_code, reason = "source closure caller is staged in Phase 2")] mod git_objects; +#[allow(dead_code, reason = "inspection recipe callers are staged in Phase 2")] +mod inspect; #[allow(dead_code, reason = "ordinary adapter command assembly is pending")] mod ordinary; #[allow(dead_code, reason = "confined execution callers are staged in Phase 2")] From cb5f16a5d795347dc488baa322f6f384aa1aa097 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 21:26:04 +0530 Subject: [PATCH 14/45] Join ordinary archive bytes to release observations --- crates/trusted-server-release/src/ordinary.rs | 3 +- .../src/ordinary/archive.rs | 2 +- .../src/ordinary/archive/tests.rs | 2 +- .../src/ordinary/run.rs | 2 +- .../src/ordinary/run/tests.rs | 6 +- .../src/ordinary/tests.rs | 4 +- .../src/ordinary/transfer.rs | 161 ++++++++++++ .../src/ordinary/transfer/tests.rs | 232 ++++++++++++++++++ 8 files changed, 403 insertions(+), 9 deletions(-) create mode 100644 crates/trusted-server-release/src/ordinary/transfer.rs create mode 100644 crates/trusted-server-release/src/ordinary/transfer/tests.rs diff --git a/crates/trusted-server-release/src/ordinary.rs b/crates/trusted-server-release/src/ordinary.rs index 115bb122e..cf42b10b5 100644 --- a/crates/trusted-server-release/src/ordinary.rs +++ b/crates/trusted-server-release/src/ordinary.rs @@ -16,6 +16,7 @@ use crate::error::ReleaseError; mod archive; mod run; +mod transfer; const PROFILE: &str = "ordinary-release-fastly-byte-equality-v1"; const ASSET: &str = "trusted-server-fastly.wasm"; @@ -307,4 +308,4 @@ pub(crate) fn observation(bytes: &[u8]) -> Result Result Vec { +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 { diff --git a/crates/trusted-server-release/src/ordinary/run.rs b/crates/trusted-server-release/src/ordinary/run.rs index 9b4a5d3d9..348588cd9 100644 --- a/crates/trusted-server-release/src/ordinary/run.rs +++ b/crates/trusted-server-release/src/ordinary/run.rs @@ -331,4 +331,4 @@ pub(super) fn bind( } #[cfg(test)] -mod tests; +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 index e0be3a8cf..b843dbc11 100644 --- a/crates/trusted-server-release/src/ordinary/run/tests.rs +++ b/crates/trusted-server-release/src/ordinary/run/tests.rs @@ -6,7 +6,7 @@ fn encoded(value: &Value) -> Vec { serde_json::to_vec(value).expect("should encode fixture") } -fn request() -> Value { +pub(in crate::ordinary) fn request() -> Value { json!({ "repository": "example-owner/example-server", "repository_id": 17, @@ -29,7 +29,7 @@ fn request() -> Value { }) } -fn jobs() -> Value { +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"), @@ -50,7 +50,7 @@ fn jobs() -> Value { json!({"total_count": jobs.len(), "jobs": jobs}) } -fn artifacts() -> Value { +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"), diff --git a/crates/trusted-server-release/src/ordinary/tests.rs b/crates/trusted-server-release/src/ordinary/tests.rs index a82e3228b..d7acee360 100644 --- a/crates/trusted-server-release/src/ordinary/tests.rs +++ b/crates/trusted-server-release/src/ordinary/tests.rs @@ -5,7 +5,7 @@ use super::{Observation, compare, observation, pair}; const WASM: &[u8] = b"\0asm\x01\0\0\0"; -fn fixture() -> Value { +pub(super) fn fixture() -> Value { json!({ "version": 1, "profile": "ordinary-release-fastly-byte-equality-v1", @@ -49,7 +49,7 @@ fn parse(value: &Value) -> Observation { .expect("should validate the observation fixture") } -fn reproduction() -> Value { +pub(super) fn reproduction() -> Value { let mut value = fixture(); value["role"] = json!("reproduction"); value["environment"]["checkout_root"] = json!("/example/reproduction/source"); 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 000000000..2169426d0 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/transfer.rs @@ -0,0 +1,161 @@ +//! 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 super::archive::{self, Profile}; +use super::run::{Archive, Bindings, Build}; +use super::{ASSET, Equality, Observation, compare, observation, require}; +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, + 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", + "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": observation.facts.artifact.size, + "sha256": observation.facts.artifact.sha256, + "spdx_sha256": spdx, + }) + } + + /// Exact canonical inputs retained from the checked build archive. + pub(super) fn inputs(&self) -> (&[u8], &[u8]) { + (&self.binary, &self.spdx) + } +} + +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", + )?; + archive::contents(bytes, profile) +} + +fn observed( + bytes: &[u8], + bindings: &Bindings, + build: &Build, +) -> Result> { + let observed = observation(bytes)?; + 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( + 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", + )?; + 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])?; + 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, + spdx, + reproduction_spdx_sha256, + }) +} + +#[cfg(test)] +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 000000000..7d8e5eb66 --- /dev/null +++ b/crates/trusted-server-release/src/ordinary/transfer/tests.rs @@ -0,0 +1,232 @@ +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] = b"\0asm\x01\0\0\0"; + +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), (&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(&tests::fixture()))], + false, + ), + build, + zip( + &[("fastly-observation.json", &encoded(&tests::reproduction()))], + 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:?}"), + } + } +} + +#[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_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, tests::fixture()), (3, tests::reproduction())] { + 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 = tests::reproduction(); + 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_binary_mutation_after_observation() { + let mut fixture = Fixture::new(); + fixture.archives[2] = zip( + &[ + (ASSET, b"different"), + (&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), (&format!("{ASSET}.spdx.json"), b"{}")], + false, + ); + let mut observation = tests::reproduction(); + 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" + ); +} From 13f63f020766a0a7b28f9c0401aa708350b563c0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 21:36:49 +0530 Subject: [PATCH 15/45] Port the closed recipe and composition dependency graph Carry the approved Phase 2 source and staged evidence-test splits from 122b78664. Validate all seven ordinary cohort rows while preserving four closed controlled destination contracts. Keep qualified selectors and release execution closed. --- Cargo.lock | 15 + crates/trusted-server-release/Cargo.toml | 5 + crates/trusted-server-release/src/boundary.rs | 513 ++++++++++ .../boundary/cleanup_and_system_root_tests.rs | 187 ++++ .../src/boundary/command.rs | 235 +++++ .../src/boundary/command/selector_tests.rs | 61 ++ .../src/boundary/daemon.rs | 342 +++++++ .../src/boundary/docker.rs | 671 +++++++++++++ .../src/boundary/docker/host_path_tests.rs | 85 ++ .../src/boundary/entropy.rs | 79 ++ .../src/boundary/host_inputs.rs | 312 ++++++ .../src/boundary/macos.rs | 389 ++++++++ .../src/boundary/probe.rs | 290 ++++++ .../src/boundary/proof.rs | 520 ++++++++++ .../src/boundary/roots.rs | 545 ++++++++++ .../src/boundary/seccomp.rs | 123 +++ .../src/boundary/selector_placement_tests.rs | 127 +++ .../src/boundary/sibling_tests.rs | 149 +++ .../src/boundary/survivors.rs | 326 ++++++ .../src/boundary/tests.rs | 236 +++++ .../src/boundary/tests/container.rs | 727 ++++++++++++++ .../src/boundary/tests/linux.rs | 377 +++++++ .../src/boundary/tests/macos.rs | 751 ++++++++++++++ .../src/boundary/tests/modes.rs | 340 +++++++ .../src/boundary/tests/unit.rs | 560 +++++++++++ .../trusted-server-release/src/composition.rs | 500 ++++++++++ .../src/composition/capture.rs | 376 +++++++ .../src/composition/external.rs | 113 +++ .../src/composition/external/tests.rs | 170 ++++ .../src/composition/graph.rs | 413 ++++++++ .../src/composition/graph/tests.rs | 492 ++++++++++ .../src/composition/javascript.rs | 169 ++++ .../src/composition/javascript/tests.rs | 151 +++ .../src/composition/native.rs | 682 +++++++++++++ .../src/composition/native/tests.rs | 664 +++++++++++++ .../src/composition/origin.rs | 403 ++++++++ .../src/composition/origin/tests.rs | 397 ++++++++ .../src/composition/platform.rs | 208 ++++ .../src/composition/platform/tests.rs | 397 ++++++++ .../src/composition/tests.rs | 491 +++++++++ crates/trusted-server-release/src/error.rs | 17 +- .../trusted-server-release/src/inspect/elf.rs | 2 +- .../trusted-server-release/src/javascript.rs | 861 ++++++++++++++++ .../src/javascript/contributions.rs | 770 +++++++++++++++ .../src/javascript/contributions/tests.rs | 201 ++++ .../src/javascript/tests.rs | 296 ++++++ .../src/javascript/tests/adopt.rs | 500 ++++++++++ .../src/lifecycle_guard.rs | 86 ++ crates/trusted-server-release/src/main.rs | 204 +++- .../trusted-server-release/src/materials.rs | 277 ++++++ .../src/materials/archive.rs | 202 ++++ .../src/materials/cargo_vendor.rs | 629 ++++++++++++ .../materials/cargo_vendor/differential.rs | 293 ++++++ .../src/materials/cargo_vendor/git_tree.rs | 458 +++++++++ .../src/materials/cargo_vendor/index.rs | 108 ++ .../src/materials/cargo_vendor/normalize.rs | 630 ++++++++++++ .../src/materials/cargo_vendor/source.rs | 119 +++ .../src/materials/cargo_vendor/tests.rs | 5 + .../src/materials/cargo_vendor/tests/git.rs | 801 +++++++++++++++ .../materials/cargo_vendor/tests/normalize.rs | 565 +++++++++++ .../materials/cargo_vendor/tests/registry.rs | 493 ++++++++++ .../materials/cargo_vendor/tests/source.rs | 102 ++ .../materials/cargo_vendor/tests/support.rs | 187 ++++ .../src/materials/cargo_vendor/writer.rs | 160 +++ .../src/materials/extract.rs | 611 ++++++++++++ .../src/materials/extract/compatibility.rs | 219 +++++ .../src/materials/extract/tests.rs | 667 +++++++++++++ .../materials/extract/tests/cargo_package.rs | 154 +++ .../extract/tests/npm_compatibility.rs | 177 ++++ .../src/materials/git.rs | 530 ++++++++++ .../src/materials/git/input.rs | 260 +++++ .../src/materials/git/tests.rs | 876 +++++++++++++++++ .../src/materials/locked.rs | 262 +++++ .../src/materials/manifest.rs | 391 ++++++++ .../src/materials/npm_bundle.rs | 314 ++++++ .../src/materials/tests.rs | 417 ++++++++ crates/trusted-server-release/src/package.rs | 367 +++++++ .../src/package/tests.rs | 398 ++++++++ crates/trusted-server-release/src/policy.rs | 827 ++++++++++++++++ .../src/policy/tests.rs | 738 ++++++++++++++ .../src/policy/tools.rs | 539 ++++++++++ .../trusted-server-release/src/preflight.rs | 314 ++++++ .../src/preflight/child.rs | 157 +++ .../src/preflight/tests.rs | 648 ++++++++++++ .../trusted-server-release/src/preparation.rs | 682 +++++++++++++ .../src/preparation/tests.rs | 118 +++ .../src/preparation/tests/full.rs | 283 ++++++ crates/trusted-server-release/src/recipe.rs | 812 +++++++++++++++ .../src/recipe/ambient.rs | 459 +++++++++ .../src/recipe/apple.rs | 747 ++++++++++++++ .../src/recipe/apple/sdk.rs | 761 ++++++++++++++ .../src/recipe/apple/toolchain.rs | 512 ++++++++++ .../src/recipe/apple/toolchain/tests.rs | 808 +++++++++++++++ .../src/recipe/artifact.rs | 292 ++++++ .../src/recipe/artifact/tests.rs | 297 ++++++ .../src/recipe/cargo_home.rs | 114 +++ .../src/recipe/cargo_home/tests.rs | 172 ++++ .../src/recipe/cargo_output.rs | 228 +++++ .../src/recipe/cargo_output/tests.rs | 312 ++++++ .../src/recipe/compare.rs | 242 +++++ .../src/recipe/completed.rs | 428 ++++++++ .../src/recipe/execute.rs | 672 +++++++++++++ .../src/recipe/fastly_tools.rs | 717 ++++++++++++++ .../src/recipe/final_paths.rs | 72 ++ .../src/recipe/fresh.rs | 131 +++ .../trusted-server-release/src/recipe/gnu.rs | 612 ++++++++++++ .../src/recipe/gnu/image.rs | 308 ++++++ .../src/recipe/gnu/tests.rs | 629 ++++++++++++ .../src/recipe/identity.rs | 147 +++ .../src/recipe/identity/tests.rs | 182 ++++ .../src/recipe/native.rs | 212 ++++ .../src/recipe/paths.rs | 79 ++ .../src/recipe/paths/fake.rs | 93 ++ .../trusted-server-release/src/recipe/plan.rs | 448 +++++++++ .../src/recipe/remap.rs | 347 +++++++ .../src/recipe/runtime.rs | 594 +++++++++++ .../src/recipe/runtime/config.rs | 340 +++++++ .../src/recipe/runtime/exchange.rs | 461 +++++++++ .../src/recipe/runtime/export.rs | 92 ++ .../src/recipe/runtime/export/tests.rs | 126 +++ .../src/recipe/runtime/logs.rs | 98 ++ .../src/recipe/runtime/viceroy.rs | 160 +++ .../src/recipe/sources.rs | 35 + .../src/recipe/startup.rs | 702 +++++++++++++ .../src/recipe/startup/accepted.rs | 347 +++++++ .../src/recipe/startup/accepted/tests.rs | 209 ++++ .../src/recipe/startup/lifecycle.rs | 193 ++++ .../src/recipe/startup/native.rs | 611 ++++++++++++ .../src/recipe/tests.rs | 380 +++++++ .../src/recipe/tests/ambient.rs | 299 ++++++ .../src/recipe/tests/bind.rs | 350 +++++++ .../src/recipe/tests/candidate.rs | 830 ++++++++++++++++ .../src/recipe/tests/cli.rs | 473 +++++++++ .../src/recipe/tests/cli_candidate.rs | 738 ++++++++++++++ .../src/recipe/tests/compare.rs | 340 +++++++ .../src/recipe/tests/complete.rs | 928 ++++++++++++++++++ .../src/recipe/tests/complete_darwin.rs | 173 ++++ .../src/recipe/tests/darwin.rs | 679 +++++++++++++ .../src/recipe/tests/darwin_toolchain.rs | 181 ++++ .../src/recipe/tests/fixture.rs | 309 ++++++ .../src/recipe/tests/full.rs | 583 +++++++++++ .../src/recipe/tests/full_macos.rs | 206 ++++ .../src/recipe/tests/macos_candidate.rs | 278 ++++++ .../src/recipe/tests/macos_cli.rs | 370 +++++++ .../src/recipe/tests/native.rs | 477 +++++++++ .../src/recipe/tests/plan.rs | 772 +++++++++++++++ .../src/recipe/tests/real_fixture.rs | 253 +++++ .../src/recipe/tests/remap.rs | 321 ++++++ .../src/recipe/tests/runtime.rs | 620 ++++++++++++ .../src/recipe/tests/sources.rs | 126 +++ .../src/recipe/tests/tools.rs | 381 +++++++ .../src/recipe/tools.rs | 292 ++++++ crates/trusted-server-release/src/source.rs | 534 ++++++++++ .../src/source/closure.rs | 112 +++ .../src/source/config.rs | 95 ++ .../trusted-server-release/src/source/git.rs | 178 ++++ .../src/source/materialize.rs | 259 +++++ .../src/source/tests.rs | 544 ++++++++++ .../src/source/tests/closure.rs | 292 ++++++ .../src/source/tests/layout.rs | 240 +++++ .../src/source/tests/objects.rs | 384 ++++++++ .../src/source/tests/process.rs | 31 + .../src/source/tests/worktree.rs | 141 +++ .../trusted-server-release/src/source/tree.rs | 225 +++++ .../src/source/worktree.rs | 235 +++++ .../trusted-server-release/tests/boundary.rs | 141 +++ .../trusted-server-release/tests/commands.rs | 607 ++++++++++++ .../tests/fixtures/cargo-denial/Cargo.lock | 19 + .../tests/fixtures/cargo-denial/Cargo.toml | 18 + .../crates/build-direct/Cargo.toml | 6 + .../cargo-denial/crates/build-direct/build.rs | 13 + .../crates/build-direct/src/main.rs | 3 + .../crates/build-grandchild/Cargo.toml | 6 + .../crates/build-grandchild/build.rs | 29 + .../crates/build-grandchild/src/main.rs | 3 + .../cargo-denial/crates/control/Cargo.toml | 6 + .../cargo-denial/crates/control/build.rs | 5 + .../crates/control/src/control.rs | 3 + .../cargo-denial/crates/control/src/main.rs | 4 + .../crates/macro-attempt/Cargo.toml | 12 + .../crates/macro-attempt/src/lib.rs | 17 + .../crates/macro-attempt/src/main.rs | 5 + .../tests/fixtures/composition/README.md | 27 + .../cargo-1.95-cross-host-metadata.json | 1 + .../cargo-1.95-cross-messages.jsonl | 6 + ...o-1.95-cross-target-filtered-metadata.json | 1 + .../composition/cargo-1.95-cross-tree.txt | 3 + .../composition/cargo-1.95-messages.jsonl | 4 + .../composition/cargo-1.95-metadata.json | 270 +++++ .../tests/fixtures/sha-mbles-1.bin | Bin 0 -> 640 bytes scripts/release/bootstrap.sh | 17 + scripts/release/qualification/SECCOMP-LICENSE | 202 ++++ .../qualification/seccomp-default.json | 875 +++++++++++++++++ scripts/release/tests/cli-builder.Dockerfile | 10 + 194 files changed, 62239 insertions(+), 10 deletions(-) create mode 100644 crates/trusted-server-release/src/boundary.rs create mode 100644 crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs create mode 100644 crates/trusted-server-release/src/boundary/command.rs create mode 100644 crates/trusted-server-release/src/boundary/command/selector_tests.rs create mode 100644 crates/trusted-server-release/src/boundary/daemon.rs create mode 100644 crates/trusted-server-release/src/boundary/docker.rs create mode 100644 crates/trusted-server-release/src/boundary/docker/host_path_tests.rs create mode 100644 crates/trusted-server-release/src/boundary/entropy.rs create mode 100644 crates/trusted-server-release/src/boundary/host_inputs.rs create mode 100644 crates/trusted-server-release/src/boundary/macos.rs create mode 100644 crates/trusted-server-release/src/boundary/probe.rs create mode 100644 crates/trusted-server-release/src/boundary/proof.rs create mode 100644 crates/trusted-server-release/src/boundary/roots.rs create mode 100644 crates/trusted-server-release/src/boundary/seccomp.rs create mode 100644 crates/trusted-server-release/src/boundary/selector_placement_tests.rs create mode 100644 crates/trusted-server-release/src/boundary/sibling_tests.rs create mode 100644 crates/trusted-server-release/src/boundary/survivors.rs create mode 100644 crates/trusted-server-release/src/boundary/tests.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/container.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/linux.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/macos.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/modes.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/unit.rs create mode 100644 crates/trusted-server-release/src/composition.rs create mode 100644 crates/trusted-server-release/src/composition/capture.rs create mode 100644 crates/trusted-server-release/src/composition/external.rs create mode 100644 crates/trusted-server-release/src/composition/external/tests.rs create mode 100644 crates/trusted-server-release/src/composition/graph.rs create mode 100644 crates/trusted-server-release/src/composition/graph/tests.rs create mode 100644 crates/trusted-server-release/src/composition/javascript.rs create mode 100644 crates/trusted-server-release/src/composition/javascript/tests.rs create mode 100644 crates/trusted-server-release/src/composition/native.rs create mode 100644 crates/trusted-server-release/src/composition/native/tests.rs create mode 100644 crates/trusted-server-release/src/composition/origin.rs create mode 100644 crates/trusted-server-release/src/composition/origin/tests.rs create mode 100644 crates/trusted-server-release/src/composition/platform.rs create mode 100644 crates/trusted-server-release/src/composition/platform/tests.rs create mode 100644 crates/trusted-server-release/src/composition/tests.rs create mode 100644 crates/trusted-server-release/src/javascript.rs create mode 100644 crates/trusted-server-release/src/javascript/contributions.rs create mode 100644 crates/trusted-server-release/src/javascript/contributions/tests.rs create mode 100644 crates/trusted-server-release/src/javascript/tests.rs create mode 100644 crates/trusted-server-release/src/javascript/tests/adopt.rs create mode 100644 crates/trusted-server-release/src/lifecycle_guard.rs create mode 100644 crates/trusted-server-release/src/materials.rs create mode 100644 crates/trusted-server-release/src/materials/archive.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/differential.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/index.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/source.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/writer.rs create mode 100644 crates/trusted-server-release/src/materials/extract.rs create mode 100644 crates/trusted-server-release/src/materials/extract/compatibility.rs create mode 100644 crates/trusted-server-release/src/materials/extract/tests.rs create mode 100644 crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs create mode 100644 crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs create mode 100644 crates/trusted-server-release/src/materials/git.rs create mode 100644 crates/trusted-server-release/src/materials/git/input.rs create mode 100644 crates/trusted-server-release/src/materials/git/tests.rs create mode 100644 crates/trusted-server-release/src/materials/locked.rs create mode 100644 crates/trusted-server-release/src/materials/manifest.rs create mode 100644 crates/trusted-server-release/src/materials/npm_bundle.rs create mode 100644 crates/trusted-server-release/src/materials/tests.rs create mode 100644 crates/trusted-server-release/src/package.rs create mode 100644 crates/trusted-server-release/src/package/tests.rs create mode 100644 crates/trusted-server-release/src/policy.rs create mode 100644 crates/trusted-server-release/src/policy/tests.rs create mode 100644 crates/trusted-server-release/src/policy/tools.rs create mode 100644 crates/trusted-server-release/src/preflight.rs create mode 100644 crates/trusted-server-release/src/preflight/child.rs create mode 100644 crates/trusted-server-release/src/preflight/tests.rs create mode 100644 crates/trusted-server-release/src/preparation.rs create mode 100644 crates/trusted-server-release/src/preparation/tests.rs create mode 100644 crates/trusted-server-release/src/preparation/tests/full.rs create mode 100644 crates/trusted-server-release/src/recipe.rs create mode 100644 crates/trusted-server-release/src/recipe/ambient.rs create mode 100644 crates/trusted-server-release/src/recipe/apple.rs create mode 100644 crates/trusted-server-release/src/recipe/apple/sdk.rs create mode 100644 crates/trusted-server-release/src/recipe/apple/toolchain.rs create mode 100644 crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/artifact.rs create mode 100644 crates/trusted-server-release/src/recipe/artifact/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/cargo_home.rs create mode 100644 crates/trusted-server-release/src/recipe/cargo_home/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/cargo_output.rs create mode 100644 crates/trusted-server-release/src/recipe/cargo_output/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/compare.rs create mode 100644 crates/trusted-server-release/src/recipe/completed.rs create mode 100644 crates/trusted-server-release/src/recipe/execute.rs create mode 100644 crates/trusted-server-release/src/recipe/fastly_tools.rs create mode 100644 crates/trusted-server-release/src/recipe/final_paths.rs create mode 100644 crates/trusted-server-release/src/recipe/fresh.rs create mode 100644 crates/trusted-server-release/src/recipe/gnu.rs create mode 100644 crates/trusted-server-release/src/recipe/gnu/image.rs create mode 100644 crates/trusted-server-release/src/recipe/gnu/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/identity.rs create mode 100644 crates/trusted-server-release/src/recipe/identity/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/native.rs create mode 100644 crates/trusted-server-release/src/recipe/paths.rs create mode 100644 crates/trusted-server-release/src/recipe/paths/fake.rs create mode 100644 crates/trusted-server-release/src/recipe/plan.rs create mode 100644 crates/trusted-server-release/src/recipe/remap.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/config.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/exchange.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/export.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/export/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/logs.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/viceroy.rs create mode 100644 crates/trusted-server-release/src/recipe/sources.rs create mode 100644 crates/trusted-server-release/src/recipe/startup.rs create mode 100644 crates/trusted-server-release/src/recipe/startup/accepted.rs create mode 100644 crates/trusted-server-release/src/recipe/startup/accepted/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/startup/lifecycle.rs create mode 100644 crates/trusted-server-release/src/recipe/startup/native.rs create mode 100644 crates/trusted-server-release/src/recipe/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/ambient.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/bind.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/candidate.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/cli.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/cli_candidate.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/compare.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/complete.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/complete_darwin.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/darwin.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/fixture.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/full.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/full_macos.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/macos_candidate.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/macos_cli.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/native.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/plan.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/real_fixture.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/remap.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/runtime.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/sources.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/tools.rs create mode 100644 crates/trusted-server-release/src/recipe/tools.rs create mode 100644 crates/trusted-server-release/src/source.rs create mode 100644 crates/trusted-server-release/src/source/closure.rs create mode 100644 crates/trusted-server-release/src/source/config.rs create mode 100644 crates/trusted-server-release/src/source/git.rs create mode 100644 crates/trusted-server-release/src/source/materialize.rs create mode 100644 crates/trusted-server-release/src/source/tests.rs create mode 100644 crates/trusted-server-release/src/source/tests/closure.rs create mode 100644 crates/trusted-server-release/src/source/tests/layout.rs create mode 100644 crates/trusted-server-release/src/source/tests/objects.rs create mode 100644 crates/trusted-server-release/src/source/tests/process.rs create mode 100644 crates/trusted-server-release/src/source/tests/worktree.rs create mode 100644 crates/trusted-server-release/src/source/tree.rs create mode 100644 crates/trusted-server-release/src/source/worktree.rs create mode 100644 crates/trusted-server-release/tests/boundary.rs create mode 100644 crates/trusted-server-release/tests/commands.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/control.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs create mode 100644 crates/trusted-server-release/tests/fixtures/composition/README.md create mode 100644 crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-host-metadata.json create mode 100644 crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-messages.jsonl create mode 100644 crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-target-filtered-metadata.json create mode 100644 crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-tree.txt create mode 100644 crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-messages.jsonl create mode 100644 crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-metadata.json create mode 100644 crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin create mode 100644 scripts/release/bootstrap.sh create mode 100644 scripts/release/qualification/SECCOMP-LICENSE create mode 100644 scripts/release/qualification/seccomp-default.json create mode 100644 scripts/release/tests/cli-builder.Dockerfile diff --git a/Cargo.lock b/Cargo.lock index 41e3707f1..10c099674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6278,6 +6278,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" @@ -6995,10 +7005,13 @@ dependencies = [ name = "trusted-server-release" version = "0.1.0" dependencies = [ + "base64", + "clap", "derive_more", "error-stack", "flate2", "hex", + "hmac", "jsonschema", "libc", "object", @@ -7006,8 +7019,10 @@ dependencies = [ "serde_json", "sha1-checked", "sha2 0.11.1", + "tar", "tempfile", "time", + "toml", "wasmparser", ] diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml index 113d70277..26cdfbc81 100644 --- a/crates/trusted-server-release/Cargo.toml +++ b/crates/trusted-server-release/Cargo.toml @@ -7,10 +7,13 @@ version.workspace = true rust-version = "1.98" [dependencies] +base64.workspace = true +clap.workspace = true derive_more.workspace = true error-stack.workspace = true flate2.workspace = true hex.workspace = true +hmac.workspace = true libc.workspace = true jsonschema = { version = "=0.58.6", default-features = false } object = { version = "=0.37.3", default-features = false, features = [ @@ -24,7 +27,9 @@ serde_json.workspace = true sha1-checked = { version = "=0.10.0", default-features = false } 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", diff --git a/crates/trusted-server-release/src/boundary.rs b/crates/trusted-server-release/src/boundary.rs new file mode 100644 index 000000000..68d2bb2d0 --- /dev/null +++ b/crates/trusted-server-release/src/boundary.rs @@ -0,0 +1,513 @@ +//! Native OS network-denial boundary (`source-owned-os-denial-v1`). +//! +//! A [`Boundary`] runs a [`ConfinedCommand`] under operating-system enforced +//! network denial: on macOS a generated deny-default `sandbox-exec` profile, +//! on Linux a Docker container with `--network none`, an effective seccomp +//! profile denying non-Unix sockets, and `--init` in Docker's default private +//! PID namespace. [`Boundary::run_confined`] requires a [`DenialProof`], +//! which [`Boundary::establish_denial`] constructs only after an unconfined +//! positive socket control connected and the confined probe was denied with +//! a real errno, for this boundary instance, in this process run. +//! +//! Every fingerprint includes a fresh per-instance random nonce, so a proof +//! never transfers to another [`Boundary`], even one with identical roots. +//! +//! # Root checks around every confined execution +//! +//! - Immutable and tool roots: every regular file must have `nlink == 1` and +//! every alias must be relative, inside its root and not dangling. Full file +//! facts (content hash, mode, inode, times) are recorded before and +//! compared after; any change rejects the run. Tool executables must +//! therefore be private single-link copies ([`ProbeProgram::staged`]), not +//! Cargo's hard-linked `target/` outputs. +//! - Writable roots: `nlink == 1` and safe aliases before the run; safe +//! aliases again after it. +//! +//! # Descendants +//! +//! On Linux the container's private PID namespace with `--init` ends every +//! descendant when the run's container is stopped and removed. On macOS: +//! +//! - The profile denies the `setsid` and `setpgid` system calls, so a +//! descendant cannot leave the supervised process group that way, and the +//! process module's group kill reaps it. +//! - `posix_spawn` with `POSIX_SPAWN_SETPGROUP`/`POSIX_SPAWN_SETSID` (Rust's +//! `Command::process_group`) bypasses that syscall filter (observed). As a +//! fail-closed backstop, after each run every process that started during +//! it and belongs to that run's sandbox instance (identified by per-run +//! marker rules through `sandbox_check`) or carries its environment nonce is +//! killed and the run rejected. Scrubbing the environment does not hide a +//! descendant, and a confined process cannot re-apply a sandbox. +//! - Survivors are found only after the run exits, so the macOS backend +//! still does not own descendant cleanup the way a container does +//! ([`Boundary::has_owned_descendant_cleanup`] stays false). It reports +//! [`DescendantCleanup::ReapedAfterRun`], which only a selection that +//! explicitly permits it may accept. +//! +//! # Sibling boundaries +//! +//! [`Boundary::sibling_without_writable`] derives a Linux container with the +//! same image, settings and roots minus the last writable root. It lets a +//! step that must not see a build's output root (whose legitimate Cargo hard +//! links the pre-run single-link scan rejects) run under its own proof, while +//! the scan policy itself stays unchanged. +//! +//! # System roots +//! +//! A host system tool directory (the Apple developer directory) is a +//! read-and-execute-only system root. It is not scanned; its identity is +//! the caller's record of versions and tool hashes. The Linux container +//! boundary refuses system roots. +//! +//! # What this module does not prove +//! +//! - It probes one IPv4 TCP loopback connect; the UDP, IPv6 and +//! documentation-address matrix belongs to Task 8 qualification. +//! - No sandbox profile or container image is qualified: +//! [`QualifiedSandboxProfile`] and [`QualifiedImage`](docker::QualifiedImage) +//! are uninhabited, so +//! production execution stays closed. +//! - The Docker client is trust-on-first-use pinned by digest for the life of +//! a boundary, not authenticated against an independent trust root. + +mod command; +pub(crate) mod daemon; +mod docker; +mod entropy; +mod host_inputs; +mod macos; +mod probe; +mod proof; +mod roots; +mod seccomp; +#[cfg(target_os = "macos")] +mod survivors; +#[cfg(test)] +mod tests; + +use std::path::Path; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +pub(crate) use command::{ConfinedCommand, Umask}; +pub(crate) use docker::{DockerSettings, ImageSelection}; +pub(crate) use entropy::random_bytes; +pub(crate) use probe::{DENIED_STATUS, ProbeProgram, ProbeReport, connect_probe, parse_loopback}; +pub(crate) use proof::{DenialProof, DenialReceipt}; +pub(crate) use roots::BoundaryRoots; + +use crate::error::ReleaseError; +use crate::process::{ProcessLimits, ProcessResult}; +use roots::{RootRole, ScanPolicy, TreeFacts}; + +/// Domain separating the instance fingerprint from platform fingerprints. +const INSTANCE_DOMAIN: &[u8] = b"trusted-server-boundary-instance-v1\n"; + +fn boundary_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// A macOS sandbox profile that passed Task 8 qualification. +/// +/// None has, so this type is uninhabited and production macOS selection +/// stays closed; tests use a `#[cfg(test)]` constructor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QualifiedSandboxProfile {} + +/// How a boundary ends the descendants a confined run started. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum DescendantCleanup { + /// Container teardown ends every descendant: the run's private PID + /// namespace dies with its container. + #[display("owned-by-container")] + OwnedByContainer, + /// After the run exits, every process of the run's sandbox instance (or + /// carrying its nonce) is found, killed and the run rejected. Survivors + /// exist until that post-run reap, so this is weaker than ownership. + #[display("post-run-reaped")] + ReapedAfterRun, +} + +/// The platform mechanism enforcing denial. +#[derive(Debug)] +enum Platform { + MacSandbox(macos::MacProfile), + LinuxContainer(Box), +} + +impl Platform { + /// The descendant cleanup this mechanism provides. + fn descendant_cleanup(&self) -> DescendantCleanup { + match self { + Self::MacSandbox(_) => DescendantCleanup::ReapedAfterRun, + Self::LinuxContainer(_) => DescendantCleanup::OwnedByContainer, + } + } +} + +/// A configured OS network-denial boundary. +#[derive(Debug)] +pub(crate) struct Boundary { + roots: BoundaryRoots, + platform: Platform, + fingerprint: [u8; 32], + /// The fingerprint of the boundary this sibling was derived from. + derived_from: Option<[u8; 32]>, +} + +/// Reports whether `path` equals its rebuilt components, so it has no `.`, +/// doubled or trailing separators (`components` silently drops them). +fn lexically_normal(path: &Path) -> bool { + path.components() + .collect::() + .as_os_str() + == path.as_os_str() +} + +/// Binds a platform fingerprint to a fresh per-instance nonce. +fn instance_fingerprint(platform: &[u8; 32]) -> Result<[u8; 32], Report> { + let nonce: [u8; 16] = random_bytes()?; + let mut hasher = Sha256::new(); + hasher.update(INSTANCE_DOMAIN); + hasher.update(platform); + hasher.update(nonce); + Ok(hasher.finalize().into()) +} + +/// Before-run facts of the immutable and tool roots. +struct FixedFacts(Vec); + +impl Boundary { + /// Returns the current instance's measured roots, controls and nonce binding. + /// + /// This binds an executed candidate; it does not qualify that candidate. + pub(crate) fn execution_fingerprint(&self) -> [u8; 32] { + self.fingerprint + } + /// Requires exact mount roles and placement for an owning recipe. + pub(crate) fn matches_roots(&self, roots: &BoundaryRoots) -> bool { + self.roots == *roots + } + + /// Reports whether container teardown owns every run descendant. + /// + /// The experimental macOS backend deliberately cannot satisfy this gate, + /// which the JS and preparation callers keep using. + pub(crate) fn has_owned_descendant_cleanup(&self) -> bool { + matches!(self.platform, Platform::LinuxContainer(_)) + } + + /// Returns how this boundary ends run descendants. + /// + /// The recipe gate admits [`DescendantCleanup::ReapedAfterRun`] only for a + /// selection that explicitly permits it. + pub(crate) fn descendant_cleanup(&self) -> DescendantCleanup { + self.platform.descendant_cleanup() + } + + /// Translates a selected host path into the command's execution namespace. + /// + /// # Errors + /// + /// Returns an error when the path is not inside this boundary's roots. + pub(crate) fn execution_path( + &self, + path: &Path, + ) -> Result> { + match &self.platform { + Platform::LinuxContainer(_) => docker::container_path(&self.roots, path), + Platform::MacSandbox(_) if self.roots.locate(path).is_some() => Ok(path.to_owned()), + _ => Err(boundary_error("execution path is outside boundary roots")), + } + } + /// Translates a path reported by a confined command back to the host. + /// + /// Only paths inside writable roots translate: a build output may not + /// name an immutable or tool file. + /// + /// # Errors + /// + /// Returns an error when the path is not inside a writable root. + pub(crate) fn host_path( + &self, + execution: &Path, + ) -> Result> { + match &self.platform { + Platform::LinuxContainer(_) => docker::host_path(&self.roots, execution), + Platform::MacSandbox(_) if !lexically_normal(execution) => Err(boundary_error( + "execution path is not in lexical normal form", + )), + Platform::MacSandbox(_) => match self.roots.locate(execution) { + Some((RootRole::Writable, _, relative)) + if relative + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))) => + { + Ok(execution.to_owned()) + } + _ => Err(boundary_error("execution path is outside writable roots")), + }, + } + } + + /// Configures the qualified macOS `sandbox-exec` boundary. + /// + /// # Errors + /// + /// Never returns: [`QualifiedSandboxProfile`] is uninhabited until a + /// profile is qualified. + pub(crate) fn mac_sandbox( + _roots: BoundaryRoots, + qualification: QualifiedSandboxProfile, + ) -> Result> { + match qualification {} + } + + /// Configures the unqualified macOS boundary for tests. + /// + /// # Errors + /// + /// As [`Self::mac_sandbox_unqualified`]. + #[cfg(test)] + pub(crate) fn mac_sandbox_for_tests( + roots: BoundaryRoots, + ) -> Result> { + Self::mac_sandbox_unqualified(roots) + } + + /// Generates the profile and binds the instance fingerprint. + fn mac_sandbox_unqualified(roots: BoundaryRoots) -> Result> { + if !cfg!(target_os = "macos") { + return Err(boundary_error("the sandbox-exec boundary is macOS-only")); + } + let profile = macos::MacProfile::generate(&roots)?; + Ok(Self { + fingerprint: instance_fingerprint(&profile.fingerprint())?, + platform: Platform::MacSandbox(profile), + roots, + derived_from: None, + }) + } + + /// Configures the Linux Docker container boundary. + /// + /// Host-owned seccomp profiles and the empty client configuration are + /// created in a fresh directory under `state_parent`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] on any target other than Linux, or + /// as [`docker::DockerBoundary::new`] does. + pub(crate) fn linux_container( + roots: BoundaryRoots, + settings: DockerSettings, + image: ImageSelection, + state_parent: &Path, + ) -> Result> { + if !cfg!(target_os = "linux") { + return Err(boundary_error( + "the Docker container boundary is Linux-only", + )); + } + let container = docker::DockerBoundary::new(&roots, settings, image, state_parent)?; + Ok(Self { + fingerprint: instance_fingerprint(&container.fingerprint())?, + platform: Platform::LinuxContainer(Box::new(container)), + roots, + derived_from: None, + }) + } + + /// Derives a sibling Linux container boundary without the writable root + /// `excluded`. + /// + /// The sibling keeps this boundary's image, Docker settings, immutable, + /// tool and system roots, and every writable root except `excluded`, + /// which must be the last one, so every remaining root keeps its index + /// and execution path. It gets fresh host inputs and a fresh instance + /// fingerprint, so it needs its own [`DenialProof`]; this boundary's + /// fingerprint is recorded as [`Self::derived_from`]. A sibling is never + /// derived again. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for a macOS or already-derived + /// boundary, an exclusion that is not the last of at least two writable + /// roots, or as [`docker::DockerBoundary::new`] does. + pub(crate) fn sibling_without_writable( + &self, + excluded: &Path, + ) -> Result> { + let Platform::LinuxContainer(container) = &self.platform else { + return Err(boundary_error( + "only a Linux container boundary derives a sibling", + )); + }; + if self.derived_from.is_some() { + return Err(boundary_error( + "a derived sibling boundary is never derived again", + )); + } + let roots = self.roots.without_last_writable(excluded)?; + let sibling = container.sibling(&roots)?; + Ok(Self { + fingerprint: instance_fingerprint(&sibling.fingerprint())?, + platform: Platform::LinuxContainer(Box::new(sibling)), + roots, + derived_from: Some(self.fingerprint), + }) + } + + /// Builds an unrunnable profile-only boundary with a chosen parent for + /// pure derivation checks. + /// + /// Its platform owns no descendant cleanup, so container-only gates still + /// reject it. + /// + /// # Errors + /// + /// As [`macos::MacProfile::generate`]. + #[cfg(test)] + pub(crate) fn derivation_fixture_for_tests( + roots: BoundaryRoots, + derived_from: Option<[u8; 32]>, + ) -> Result> { + Ok(Self { + platform: Platform::MacSandbox(macos::MacProfile::generate(&roots)?), + roots, + fingerprint: [2; 32], + derived_from, + }) + } + + /// Returns the fingerprint of the boundary this sibling was derived from. + pub(crate) fn derived_from(&self) -> Option<[u8; 32]> { + self.derived_from + } + + /// Runs the unconfined control and the confined probe for this instance + /// and returns the proof only when both behave. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the control or denial fails, + /// or [`ReleaseError::Process`] when a probe cannot be run. + pub(crate) fn establish_denial( + &self, + probe: &ProbeProgram, + limits: ProcessLimits, + ) -> Result> { + proof::establish(self, probe, limits) + } + + /// Runs `command` confined, requiring a proof for this instance. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for a proof from another instance, a + /// rejected or changed root, a surviving escaped descendant, or a command + /// outside the roots, or [`ReleaseError::Process`] when the child cannot + /// be run. + pub(crate) fn run_confined( + &self, + command: &ConfinedCommand, + proof: &DenialProof, + ) -> Result> { + if !proof.matches(&self.fingerprint) { + return Err(boundary_error( + "denial proof belongs to a different boundary instance", + )); + } + self.execute(command) + } + + /// Checks every root, runs `command` confined, then rechecks them. + fn execute(&self, command: &ConfinedCommand) -> Result> { + self.check_placement(command)?; + let before = self.scan_fixed()?; + self.scan_writable(ScanPolicy::SingleLinks)?; + let outcome = match &self.platform { + Platform::MacSandbox(profile) => profile.run(command), + Platform::LinuxContainer(container) => container.run(&self.roots, command), + }; + let after = self.verify_after(&before); + match (outcome, after) { + (Ok(result), Ok(())) => Ok(result), + (Ok(_), Err(changed)) => Err(changed), + (Err(run), Ok(())) => Err(run), + (Err(run), Err(changed)) => { + Err(run.attach(format!("roots also failed their checks: {changed:?}"))) + } + } + } + + fn fixed_roots(&self) -> impl Iterator { + self.roots + .immutable() + .iter() + .chain(self.roots.tools()) + .map(std::path::PathBuf::as_path) + } + + fn scan_fixed(&self) -> Result> { + self.fixed_roots() + .map(|root| TreeFacts::scan(root, ScanPolicy::Immutable)) + .collect::>() + .map(FixedFacts) + } + + fn scan_writable(&self, policy: ScanPolicy) -> Result<(), Report> { + for root in self.roots.writable() { + TreeFacts::scan(root, policy)?; + } + Ok(()) + } + + fn verify_after(&self, before: &FixedFacts) -> Result<(), Report> { + let after = self.scan_fixed()?; + for (before, after) in before.0.iter().zip(&after.0) { + before.ensure_unchanged(after)?; + } + self.scan_writable(ScanPolicy::AliasesOnly) + } + + /// Requires every tool selector to name exactly `/bin/rustc` + /// in the execution namespace, so a build cannot select another compiler. + fn check_tool_selectors(&self, command: &ConfinedCommand) -> Result<(), Report> { + for (name, value) in command.tool_selector_entries() { + let allowed = self.roots.tools().iter().any(|tool_root| { + self.execution_path(&tool_root.join("bin").join("rustc")) + .is_ok_and(|expected| expected.as_os_str() == value.as_os_str()) + }); + if !allowed { + return Err(boundary_error(format!( + "tool selector {name:?} is not the bin/rustc of a tool root" + ))); + } + } + Ok(()) + } + + fn check_placement(&self, command: &ConfinedCommand) -> Result<(), Report> { + if self.roots.locate(&command.program).is_none() { + return Err(boundary_error( + "confined program is outside every boundary root", + )); + } + self.check_tool_selectors(command)?; + match self.roots.locate(&command.working_directory) { + Some((RootRole::Immutable | RootRole::Writable, _, _)) => Ok(()), + _ => Err(boundary_error( + "confined working directory is outside the immutable and writable roots", + )), + } + } +} + +#[cfg(test)] +mod cleanup_and_system_root_tests; +#[cfg(test)] +mod selector_placement_tests; +#[cfg(test)] +mod sibling_tests; diff --git a/crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs b/crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs new file mode 100644 index 000000000..f10022eec --- /dev/null +++ b/crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs @@ -0,0 +1,187 @@ +//! Pure tests of descendant-cleanup reporting and system roots; nothing runs. + +use std::fs; +use std::path::PathBuf; + +use super::macos::MacProfile; +use super::*; + +struct Layout { + _directory: tempfile::TempDir, + base: PathBuf, +} + +fn layout() -> Layout { + let directory = tempfile::tempdir().expect("should create layout"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize layout"); + for relative in [ + "checkout", + "tools/bin", + "state", + "developer", + "developer-file", + ] { + fs::create_dir_all(base.join(relative)).expect("should create root"); + } + Layout { + _directory: directory, + base, + } +} + +impl Layout { + fn roots(&self) -> BoundaryRoots { + BoundaryRoots::new( + &[self.base.join("checkout")], + &[self.base.join("tools")], + &[self.base.join("state")], + ) + .expect("should build roots") + } + + fn with_developer(&self) -> BoundaryRoots { + self.roots() + .with_system(&[self.base.join("developer")]) + .expect("should add the system root") + } +} + +#[test] +fn mac_sandbox_reports_post_run_reaped_cleanup() { + let layout = layout(); + let profile = MacProfile::generate(&layout.roots()).expect("should generate the profile"); + + let cleanup = Platform::MacSandbox(profile).descendant_cleanup(); + + assert_eq!( + cleanup, + DescendantCleanup::ReapedAfterRun, + "should report that macOS survivors are reaped only after the run" + ); + assert_eq!( + cleanup.to_string(), + "post-run-reaped", + "should label the cleanup mode for measurements" + ); + assert_eq!( + DescendantCleanup::OwnedByContainer.to_string(), + "owned-by-container", + "should label container-owned cleanup for measurements" + ); +} + +#[test] +fn system_roots_are_read_and_execute_only_in_the_profile() { + let layout = layout(); + let profile = + MacProfile::generate(&layout.with_developer()).expect("should generate the profile"); + let text = profile.text(); + + assert!( + text.contains("(allow file-read* process-exec (subpath (param \"SYSTEM_0\")))"), + "should allow only reading and executing below the system root: {text}" + ); + assert!( + text.contains("(deny file-write* (subpath (param \"SYSTEM_0\")))"), + "should explicitly deny writes below the system root" + ); + assert!( + !text.contains("file-write* process-exec (subpath (param \"SYSTEM_0\"))"), + "should never allow writes below the system root" + ); + assert!( + !text.contains("unix-socket (subpath (param \"SYSTEM_0\"))"), + "should never allow sockets below the system root" + ); + for denied in [ + "/usr/bin/sudo", + "/usr/bin/su", + "/usr/bin/login", + "/usr/bin/newgrp", + "/usr/bin/xcrun", + "/usr/bin/xcodebuild", + "/usr/bin/xcode-select", + ] { + assert!( + text.contains(&format!("(deny process-exec (literal \"{denied}\"))")), + "should refuse to execute {denied}" + ); + } + for allowed in ["/usr/bin/cc", "/usr/bin/clang", "/usr/bin/ld"] { + assert!( + !text.contains(&format!("(literal \"{allowed}\")")), + "should leave the {allowed} shim to build scripts" + ); + } + assert!( + profile + .defines() + .iter() + .any(|(name, value)| name == "SYSTEM_0" + && Some(value.as_str()) == layout.base.join("developer").to_str()), + "should bind the system root through a define" + ); +} + +#[test] +fn system_roots_take_part_in_equality_and_the_fingerprint() { + let layout = layout(); + let plain = layout.roots(); + let with_developer = layout.with_developer(); + + assert_ne!( + plain, with_developer, + "should not treat roots without the system root as the same roots" + ); + let plain_profile = MacProfile::generate(&plain).expect("should generate the profile"); + let developer_profile = + MacProfile::generate(&with_developer).expect("should generate the profile"); + assert_ne!( + plain_profile.fingerprint(), + developer_profile.fingerprint(), + "should bind the system root into the profile fingerprint" + ); + assert_eq!( + with_developer.system(), + [layout.base.join("developer")], + "should keep the canonical system root" + ); + assert!( + with_developer + .locate(&layout.base.join("developer/usr/bin/clang")) + .is_some_and(|(role, _, _)| role == roots::RootRole::System), + "should locate tools below the system root" + ); +} + +#[test] +fn system_roots_must_be_disjoint_directories() { + let layout = layout(); + fs::write(layout.base.join("developer-file/tool"), b"fictional").expect("should write file"); + let cases = [ + ("nested in the checkout", layout.base.join("checkout/inner")), + ("containing the tools", layout.base.clone()), + ("a regular file", layout.base.join("developer-file/tool")), + ("missing", layout.base.join("absent")), + ]; + fs::create_dir_all(layout.base.join("checkout/inner")).expect("should create nested root"); + for (label, system) in cases { + assert!( + layout.roots().with_system(&[system]).is_err(), + "should reject a system root {label}" + ); + } +} + +#[test] +fn container_boundary_refuses_system_roots() { + let layout = layout(); + + let error = docker::mounts(&layout.with_developer()).expect_err("should refuse system roots"); + + assert!( + format!("{error:?}").contains("refuses host system roots"), + "should name the refused system root: {error:?}" + ); + docker::mounts(&layout.roots()).expect("should mount roots without a system root"); +} diff --git a/crates/trusted-server-release/src/boundary/command.rs b/crates/trusted-server-release/src/boundary/command.rs new file mode 100644 index 000000000..e6b534dbb --- /dev/null +++ b/crates/trusted-server-release/src/boundary/command.rs @@ -0,0 +1,235 @@ +//! The command description a [`Boundary`](super::Boundary) runs confined. + +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::os::unix::ffi::OsStrExt as _; +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use crate::error::ReleaseError; +pub(crate) use crate::process::Umask; +use crate::process::{BoundedCommand, Cancellation, ProcessLimits}; + +/// A child to run inside a [`Boundary`](super::Boundary), described by host +/// paths. +/// +/// The program must lie inside a boundary root and the working directory +/// inside an immutable or writable root; on Linux both are translated to the +/// container namespace. Both must be absolute with only normal components +/// (no `.` or `..`), so the root they are located in is the root they reach. +/// Arguments are passed verbatim. Environment values are validated by the +/// same rules as [`BoundedCommand::environment`]; secret values are not +/// supported because Docker would carry them in argv. +pub(crate) struct ConfinedCommand { + pub(super) program: PathBuf, + pub(super) arguments: Vec, + pub(super) working_directory: PathBuf, + pub(super) environment: Vec<(OsString, OsString)>, + /// Names in `environment` added through [`Self::tool_environment`]. + tool_selectors: Vec, + pub(super) stdin: Option>, + pub(super) limits: ProcessLimits, + pub(super) cancellation: Option, + pub(super) umask: Option, +} + +impl fmt::Debug for ConfinedCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ConfinedCommand") + .field("program", &self.program) + .field("arguments", &self.arguments) + .field("working_directory", &self.working_directory) + .field( + "environment_names", + &self + .environment + .iter() + .map(|(name, _)| name) + .collect::>(), + ) + .field("stdin_bytes", &self.stdin.as_ref().map(Vec::len)) + .field("limits", &self.limits) + .field("cancellation", &self.cancellation) + .field("umask", &self.umask) + .finish() + } +} + +fn invalid_path(path: &Path) -> Report { + Report::new(ReleaseError::Boundary).attach(format!( + "confined path {} must be absolute with only normal components", + path.display() + )) +} + +/// Rejects relative paths and `.`/`..` segments. +/// +/// The raw bytes are split because [`Path::components`] silently drops +/// interior `.` segments. +fn require_normal(path: &Path) -> Result<(), Report> { + let dotted = path + .as_os_str() + .as_bytes() + .split(|byte| *byte == b'/') + .any(|segment| segment == b"." || segment == b".."); + if !path.has_root() || dotted { + return Err(invalid_path(path)); + } + Ok(()) +} + +impl ConfinedCommand { + /// Describes `program` running in `working_directory` under `limits`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when either path is relative or has + /// a `.` or `..` component. + pub(crate) fn new( + program: &Path, + working_directory: &Path, + limits: ProcessLimits, + ) -> Result> { + require_normal(program)?; + require_normal(working_directory)?; + Ok(Self { + program: program.to_path_buf(), + arguments: Vec::new(), + working_directory: working_directory.to_path_buf(), + environment: Vec::new(), + tool_selectors: Vec::new(), + stdin: None, + limits, + cancellation: None, + umask: None, + }) + } + + /// Adds an explicitly selected tool path such as `RUSTC`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] for any name or value + /// [`BoundedCommand::tool_selector_environment`] rejects, including a + /// duplicate. + pub(crate) fn tool_environment( + mut self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + self.validated(None)? + .tool_selector_environment(name.as_ref(), value.as_ref())?; + self.environment + .push((name.as_ref().to_os_string(), value.as_ref().to_os_string())); + self.tool_selectors.push(name.as_ref().to_os_string()); + Ok(self) + } + + /// Appends fixed arguments. + #[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 [`ReleaseError::Process`] for any name or value + /// [`BoundedCommand::environment`] rejects, including a duplicate. + pub(crate) fn environment( + mut self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + self.validated(Some((name.as_ref(), value.as_ref())))?; + self.environment + .push((name.as_ref().to_os_string(), value.as_ref().to_os_string())); + Ok(self) + } + + /// Returns whether `name` was added through [`Self::tool_environment`]. + pub(super) fn is_tool_selector(&self, name: &OsStr) -> bool { + self.tool_selectors.iter().any(|selected| selected == name) + } + + /// Returns the tool-selector entries, for the boundary's placement check. + pub(super) fn tool_selector_entries(&self) -> impl Iterator { + self.environment + .iter() + .filter(|(name, _)| self.is_tool_selector(name)) + .map(|(name, value)| (name, value)) + } + + /// Validates the environment plus `extra` through a scratch + /// [`BoundedCommand`]. + pub(super) fn validated( + &self, + extra: Option<(&OsStr, &OsStr)>, + ) -> Result> { + let mut validator = + BoundedCommand::new(&self.program, &self.working_directory, self.limits)?; + for (name, value) in self.environment.iter() { + validator = if self.tool_selectors.contains(name) { + validator.tool_selector_environment(name, value)? + } else { + validator.environment(name, value)? + }; + } + if let Some((name, value)) = extra { + validator = validator.environment(name, value)?; + } + Ok(validator) + } + + /// Supplies fixed stdin bytes. + #[must_use] + pub(crate) fn stdin_bytes(mut self, bytes: Vec) -> Self { + self.stdin = Some(bytes); + self + } + + /// Observes `cancellation`. + #[must_use] + pub(crate) fn cancellation(mut self, cancellation: Cancellation) -> Self { + self.cancellation = Some(cancellation); + self + } + + /// Runs the child under `umask`. + /// + /// macOS sets it in the child just before `sandbox-exec` executes; Linux + /// runs the program through `/bin/sh` of the selected image, which must + /// exist there. The orchestrator's own umask is never changed. + #[must_use] + pub(crate) fn umask(mut self, umask: Umask) -> Self { + self.umask = Some(umask); + self + } + + /// Returns the requested umask, for tests of command construction. + #[cfg(test)] + pub(crate) fn requested_umask(&self) -> Option { + self.umask + } + + /// Applies stdin and cancellation to the wrapping bounded command. + pub(super) fn finish(&self, mut bounded: BoundedCommand) -> BoundedCommand { + if let Some(bytes) = &self.stdin { + bounded = bounded.stdin_bytes(bytes.clone()); + } + if let Some(cancellation) = &self.cancellation { + bounded = bounded.cancellation(cancellation.clone()); + } + bounded + } +} + +#[cfg(test)] +mod selector_tests; diff --git a/crates/trusted-server-release/src/boundary/command/selector_tests.rs b/crates/trusted-server-release/src/boundary/command/selector_tests.rs new file mode 100644 index 000000000..83fa19bcf --- /dev/null +++ b/crates/trusted-server-release/src/boundary/command/selector_tests.rs @@ -0,0 +1,61 @@ +//! Pure tests of confined tool-selector variables; no process is started. + +use std::ffi::OsString; +use std::path::Path; +use std::time::Duration; + +use super::*; + +fn command() -> ConfinedCommand { + ConfinedCommand::new( + Path::new("/example/bin/cargo"), + Path::new("/example/checkout"), + ProcessLimits { + wall: Duration::from_secs(1), + stdout_max: 1024, + stderr_max: 1024, + }, + ) + .expect("should describe a confined command") +} + +#[test] +fn rustc_needs_the_explicit_tool_method_and_survives_later_variables() { + assert!( + command() + .environment("RUSTC", "/example/bin/rustc") + .is_err(), + "should reject RUSTC through the generic environment" + ); + let described = command() + .tool_environment("RUSTC", "/example/bin/rustc") + .expect("should accept the explicit tool selector") + .environment("TZ", "UTC") + .expect("should accept a later ordinary variable") + .tool_environment("RUSTC_WRAPPER", "/example/wrapper"); + assert!(described.is_err(), "should reject any other tool selector"); + + let described = command() + .tool_environment("RUSTC", "/example/bin/rustc") + .expect("should accept the explicit tool selector") + .environment("TZ", "UTC") + .expect("should accept a later ordinary variable"); + assert_eq!( + described.environment, + [ + ( + OsString::from("RUSTC"), + OsString::from("/example/bin/rustc") + ), + (OsString::from("TZ"), OsString::from("UTC")), + ], + "should carry both variables to the platform runner in order" + ); + assert!( + command() + .tool_environment("RUSTC", "/example/bin/rustc") + .and_then(|command| command.environment("RUSTC", "/example/other")) + .is_err(), + "should reject a second RUSTC through the generic environment" + ); +} diff --git a/crates/trusted-server-release/src/boundary/daemon.rs b/crates/trusted-server-release/src/boundary/daemon.rs new file mode 100644 index 000000000..92e57bb08 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/daemon.rs @@ -0,0 +1,342 @@ +//! The pinned Docker client and its daemon-socket checks. +//! +//! Before every Docker host command the client rehashes its pinned inputs +//! (see [`HostInputs`]) and rechecks the daemon socket: the socket itself +//! must be a root-owned socket reached without a final alias, and every +//! ancestor directory, both on the configured path and on its canonical +//! form (for example `/var/run` → `/run`), must be root-owned and not group- +//! or other-writable, so no unprivileged user can substitute the endpoint. +//! +//! The client executable is trust-on-first-use pinned by digest for the life +//! of a boundary; it is not authenticated against an independent root. +//! Production use stays closed through the uninhabited +//! [`QualifiedImage`](super::docker::QualifiedImage). + +use std::ffi::OsString; +use std::fs; +use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _}; +use std::path::Path; + +use error_stack::Report; + +use super::host_inputs::HostInputs; +use crate::documents::{Role as DocumentRole, parse_json}; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessLimits, ProcessResult}; + +/// The fixed Docker daemon endpoint. +pub(crate) const DOCKER_HOST: &str = "unix:///var/run/docker.sock"; +/// The daemon socket behind [`DOCKER_HOST`]. +pub(crate) const DOCKER_SOCKET: &str = "/var/run/docker.sock"; +/// Group and other write permission bits. +const SHARED_WRITE: u32 = 0o022; + +fn daemon_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Requires every ancestor of `path` to be root-owned, and every ancestor +/// directory not group- or other-writable. +fn check_ancestors(path: &Path) -> Result<(), Report> { + for ancestor in path.ancestors().skip(1) { + let metadata = fs::symlink_metadata(ancestor).map_err(|error| { + daemon_error(format!("cannot inspect {}: {error}", ancestor.display())) + })?; + let shared = metadata.is_dir() && metadata.mode() & SHARED_WRITE != 0; + if metadata.uid() != 0 || shared { + return Err(daemon_error(format!( + "Docker socket ancestor {} is not root-owned and protected", + ancestor.display() + ))); + } + } + Ok(()) +} + +/// Checks the daemon socket at `path`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the socket is missing, is an +/// alias, is not a root-owned socket, or any ancestor (configured or +/// canonical) is not root-owned or is group- or other-writable. +pub(crate) fn check_daemon_socket(path: &Path) -> Result<(), Report> { + let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else { + return Err(daemon_error("Docker socket path has no parent")); + }; + check_ancestors(path)?; + let canonical = fs::canonicalize(parent) + .map_err(|error| daemon_error(format!("cannot resolve Docker socket parent: {error}")))? + .join(name); + check_ancestors(&canonical)?; + let metadata = fs::symlink_metadata(&canonical) + .map_err(|error| daemon_error(format!("Docker daemon socket unavailable: {error}")))?; + if !metadata.file_type().is_socket() || metadata.uid() != 0 { + return Err(daemon_error( + "Docker daemon endpoint is not a root-owned socket (aliases are rejected)", + )); + } + Ok(()) +} + +/// Returns the client-level arguments selecting the empty config and daemon. +#[must_use] +pub(crate) fn host_arguments(config: &Path) -> Vec { + vec![ + OsString::from("--config"), + config.as_os_str().to_os_string(), + OsString::from("--host"), + OsString::from(DOCKER_HOST), + ] +} + +/// A pinned Docker client that rehashes its inputs before every command. +#[derive(Debug)] +pub(crate) struct DockerClient { + inputs: HostInputs, + host_limits: ProcessLimits, +} + +impl DockerClient { + /// Creates pinned host inputs under `state_parent`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] as [`HostInputs::create`] does. + pub(crate) fn new( + client: &Path, + state_parent: &Path, + mounted: &[&Path], + host_limits: ProcessLimits, + ) -> Result> { + Ok(Self { + inputs: HostInputs::create(state_parent, client, mounted)?, + host_limits, + }) + } + + /// Returns the pinned inputs. + #[must_use] + pub(crate) fn inputs(&self) -> &HostInputs { + &self.inputs + } + + /// Builds a client command after rehashing every pinned input and + /// rechecking the daemon socket. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when an input changed or the socket + /// check fails, or [`ReleaseError::Process`] for an invalid command. + pub(crate) fn command( + &self, + arguments: Vec, + limits: ProcessLimits, + ) -> Result> { + self.inputs.verify()?; + self.control_command(arguments, limits) + } + + /// Builds a command after checking its client, config and daemon inputs. + fn control_command( + &self, + arguments: Vec, + limits: ProcessLimits, + ) -> Result> { + self.inputs.verify_control()?; + check_daemon_socket(Path::new(DOCKER_SOCKET))?; + Ok( + BoundedCommand::new(self.inputs.client(), self.inputs.directory(), limits)? + .arguments(arguments), + ) + } + + fn full_arguments(&self, arguments: &[&str]) -> Vec { + let mut full = host_arguments(self.inputs.config()); + full.extend(arguments.iter().map(OsString::from)); + full + } + + /// Requires the selected image not to declare anonymous writable volumes. + /// + /// # Errors + /// + /// Returns an error when image inspection fails or its volume declaration + /// is malformed or nonempty. Inspection uses the pinned host client. + pub(crate) fn require_no_image_volumes(&self, image: &str) -> Result<(), Report> { + let result = self.host(&[ + "image", + "inspect", + "--format", + "{{json .Config.Volumes}}", + image, + ])?; + validate_image_volumes(&result.require_success()?) + } + + /// Creates a stopped container and requires its acknowledged full identity. + /// + /// Callers must supply fixed `create` arguments and must not issue a start + /// request before this returns successfully. Killing a create client cannot + /// cancel its daemon RPC, so an error leaves creation explicitly unresolved. + /// + /// # Errors + /// + /// Returns an error for changed host inputs, failed creation or an invalid + /// ID. A late stopped container remains possible after any failure; name + /// cleanup and an absence observation cannot turn that failure into success. + pub(crate) fn create( + &self, + arguments: Vec, + limits: ProcessLimits, + ) -> Result> { + self.command(arguments, limits) + .and_then(|command| command.run()) + .and_then(ProcessResult::require_success) + .and_then(|stdout| ContainerId::parse(&stdout)) + .map_err(|error| error.attach( + "container creation is unresolved; a late stopped container may remain; no start is authorized" + )) + } + + /// Runs one host command (`docker --config … --host … `). + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when an input changed or the socket + /// check fails before the command, or [`ReleaseError::Process`] when the + /// child cannot be run. + pub(crate) fn host(&self, arguments: &[&str]) -> Result> { + self.command(self.full_arguments(arguments), self.host_limits)? + .run() + } + + /// Runs one cleanup command, rehashing inputs first when they are still + /// intact. Profile-only failures are recorded while safe cleanup is + /// attempted; a changed client or config prevents all execution. + pub(crate) fn cleanup_host( + &self, + arguments: &[&str], + failures: &mut Vec>, + ) -> Option { + if let Err(integrity) = self.inputs.verify_control() { + failures.push(integrity); + return None; + } + let full = self.full_arguments(arguments); + if let Err(integrity) = self.inputs.verify() { + failures.push(integrity); + } + let command = self.control_command(full, self.host_limits); + match command.and_then(|command| command.run()) { + Ok(result) => Some(result), + Err(error) => { + failures.push(error); + None + } + } + } +} + +/// Full immutable identity acknowledged by a successful Docker create command. +#[derive(Debug)] +pub(crate) struct ContainerId(String); + +impl ContainerId { + /// Parses exactly one full lowercase hexadecimal container ID. + /// + /// # Errors + /// + /// Returns an error for abbreviated IDs, names, extra output or invalid bytes. + pub(crate) fn parse(stdout: &[u8]) -> Result> { + let bytes = stdout.strip_suffix(b"\n").unwrap_or(stdout); + if bytes.len() != 64 + || !bytes + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + return Err(daemon_error( + "container creation returned no unambiguous full identity", + )); + } + let id = std::str::from_utf8(bytes) + .map_err(|error| daemon_error(format!("container identity is not UTF-8: {error}")))?; + Ok(Self(id.to_owned())) + } + + /// Returns the full daemon-owned container identity. + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod create_tests { + use super::*; + + #[test] + fn created_container_identity_is_full_and_unambiguous() { + let id = "a".repeat(64); + for bytes in [id.clone(), format!("{id}\n")] { + assert_eq!( + ContainerId::parse(bytes.as_bytes()) + .expect("should accept one full ID") + .as_str(), + id + ); + } + for invalid in [ + String::new(), + "a".repeat(12), + "A".repeat(64), + format!("sha256:{id}"), + format!("{id}\nextra"), + format!(" {id}"), + format!("{id}\n\n"), + ] { + assert!( + ContainerId::parse(invalid.as_bytes()).is_err(), + "should reject ambiguous container identity {invalid:?}" + ); + } + } +} + +#[cfg(test)] +mod volume_tests { + use super::*; + + #[test] + fn image_volumes_must_be_absent_or_an_empty_object() { + for accepted in [b"null".as_slice(), b"{}\n"] { + validate_image_volumes(accepted) + .expect("should accept an image without declared volumes"); + } + for rejected in [ + b"{\"/scratch\":{}}".as_slice(), + b"[]", + b"\"null\"", + b"true", + b"", + b"{} trailing", + b"{\"x\":{},\"x\":{}}", + ] { + assert!( + validate_image_volumes(rejected).is_err(), + "should reject malformed or writable image volumes: {rejected:?}" + ); + } + } +} + +/// Accepts only the Docker image volume JSON values that declare no mounts. +fn validate_image_volumes(stdout: &[u8]) -> Result<(), Report> { + let value = parse_json(stdout, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Boundary))?; + if value.is_null() || value.as_object().is_some_and(serde_json::Map::is_empty) { + return Ok(()); + } + Err(daemon_error( + "selected image declares writable volumes or malformed volume metadata", + )) +} diff --git a/crates/trusted-server-release/src/boundary/docker.rs b/crates/trusted-server-release/src/boundary/docker.rs new file mode 100644 index 000000000..c45521ece --- /dev/null +++ b/crates/trusted-server-release/src/boundary/docker.rs @@ -0,0 +1,671 @@ +//! Linux Docker container boundary. +//! +//! Every container runs with `--init` in Docker's default private PID +//! namespace (never the invalid `--pid=private`), `--network none`, the +//! effective seccomp profile (every non-Unix socket denied with `EPERM`), a read-only +//! root filesystem, all capabilities dropped, `no-new-privileges`, private +//! IPC without a `/dev/shm` mount, an empty read-only `/tmp`, a non-root user +//! and CPU, memory and process limits that fit the +//! actual host. The client is a fixed absolute executable invoked with an +//! explicit empty `--config` and the fixed Unix daemon socket, a cleared +//! environment (no `DOCKER_HOST`, `DOCKER_CONTEXT` or `DOCKER_CONFIG`), no TTY +//! and `--pull never`. +//! +//! Mounts use `--mount type=bind`, which fails on a missing source instead of +//! creating it the way `-v` does. Immutable roots and tools are mounted +//! read-only and recursively read-only (`bind-recursive=readonly`), writable +//! roots read-write, at fixed `/boundary//` targets. +//! +//! Creation must acknowledge a full container ID before a separate attached +//! start can execute the guest. A failed creation remains unresolved and fatal; +//! a later name lookup cannot exclude a delayed daemon-side creation. +//! Every acknowledged start is followed, on success and failure alike, by bounded +//! `docker stop --time `, `docker rm --force` and a listing that must show +//! the acknowledged container ID gone. Each step is attempted even when an earlier one +//! failed, and every failure is reported. If the pinned host inputs fail +//! their integrity check during cleanup, cleanup still proceeds through the +//! pinned client path (a left-running container is the worse outcome) and the +//! integrity failure is returned. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::daemon::{ + ContainerId, DOCKER_SOCKET, DockerClient, check_daemon_socket, host_arguments, +}; +use super::roots::{BoundaryRoots, RootRole, validate_path_text}; +use super::{ConfinedCommand, Umask}; +use crate::error::ReleaseError; +use crate::process::{ProcessLimits, ProcessOutcome, ProcessResult}; + +/// Container-namespace prefix for every mount target. +const MOUNT_PREFIX: &str = "/boundary"; +/// Configuration fingerprint domain. +const FINGERPRINT_DOMAIN: &[u8] = b"trusted-server-boundary-docker-confirmed-create-v2\n"; +/// Fixed absolute shell of the selected image that applies a requested umask. +const SHELL: &str = "/bin/sh"; +/// Fixed `argv[0]` of [`SHELL`]; the program and its arguments follow it. +const SHELL_NAME: &str = "sh"; + +fn docker_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// A production OCI image that passed Task 8 qualification. +/// +/// No image is qualified yet (`release-tools.json` keeps the GNU builder +/// `image_reference` null), so this type is uninhabited and production +/// selection stays closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QualifiedImage {} + +/// The image a container boundary runs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ImageSelection { + /// A qualified production image (currently impossible to construct). + Qualified(QualifiedImage), + /// A test-only local image resolved to its content ID. + #[cfg(test)] + Test { id: String }, +} + +impl ImageSelection { + fn into_id(self) -> String { + match self { + Self::Qualified(image) => match image {}, + #[cfg(test)] + Self::Test { id } => id, + } + } +} + +/// Validates a local image content ID (`sha256:<64 lowercase hex>`). +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] for anything else. +pub(crate) fn validate_image_id(id: &str) -> Result<(), Report> { + let valid = id.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }); + if !valid { + return Err(docker_error("image is not pinned by a sha256 content ID")); + } + Ok(()) +} + +/// Container resource and cleanup settings. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DockerSettings { + /// Absolute fixed Docker client executable. + pub(crate) client: PathBuf, + /// Whole CPUs; never more than the host's available parallelism. + pub(crate) cpus: u32, + /// Memory limit in bytes; never more than host physical memory. + pub(crate) memory_bytes: u64, + /// Maximum container processes. + pub(crate) pids_limit: u32, + /// Size bound of the empty read-only `/tmp` tmpfs in bytes. + pub(crate) tmpfs_bytes: u64, + /// Non-root container user and group IDs. + pub(crate) user: (u32, u32), + /// `docker stop -t` grace in seconds. + pub(crate) stop_timeout_seconds: u32, + /// Limits for Docker creation and host cleanup commands. + pub(crate) host_limits: ProcessLimits, +} + +/// Returns host physical memory in bytes, when the OS reports it. +fn physical_memory() -> Option { + // SAFETY: `sysconf` has no preconditions and only reads system values. + let (pages, page_size) = unsafe { + ( + libc::sysconf(libc::_SC_PHYS_PAGES), + libc::sysconf(libc::_SC_PAGESIZE), + ) + }; + let pages = u64::try_from(pages).ok()?; + let page_size = u64::try_from(page_size).ok()?; + pages.checked_mul(page_size) +} + +impl DockerSettings { + /// Checks the settings against the actual host capacity. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for zero limits, CPUs above + /// [`std::thread::available_parallelism`], memory above physical memory, + /// a root user, or a stop grace not below the host command deadline. + pub(crate) fn validate(&self) -> Result<(), Report> { + let parallelism = std::thread::available_parallelism() + .map_err(|error| docker_error(format!("cannot read host parallelism: {error}")))?; + let parallelism = u32::try_from(parallelism.get()).unwrap_or(u32::MAX); + if self.cpus == 0 || self.cpus > parallelism { + return Err(docker_error(format!( + "container CPUs {} must be between 1 and host parallelism {parallelism}", + self.cpus + ))); + } + let memory = + physical_memory().ok_or_else(|| docker_error("cannot read host physical memory"))?; + if self.memory_bytes == 0 || self.memory_bytes > memory { + return Err(docker_error(format!( + "container memory {} must be nonzero and at most host memory {memory}", + self.memory_bytes + ))); + } + if self.pids_limit == 0 || self.tmpfs_bytes == 0 || self.user.0 == 0 { + return Err(docker_error( + "pids, tmpfs and non-root user must all be set", + )); + } + let stop = std::time::Duration::from_secs(u64::from(self.stop_timeout_seconds)); + if self.stop_timeout_seconds == 0 || stop >= self.host_limits.wall { + return Err(docker_error( + "stop grace must be nonzero and below the host deadline", + )); + } + Ok(()) + } +} + +/// One bind mount into the container namespace. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Mount { + source: PathBuf, + target: String, + read_only: bool, +} + +fn role_directory(role: RootRole) -> &'static str { + match role { + RootRole::Immutable => "immutable", + RootRole::Tool => "tool", + RootRole::Writable => "writable", + RootRole::System => "system", + } +} + +/// Returns the container path of `host`, which must lie inside a root. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `host` is outside every root. +pub(crate) fn container_path( + roots: &BoundaryRoots, + host: &Path, +) -> Result> { + let (role, index, relative) = roots.locate(host).ok_or_else(|| { + docker_error(format!("{} is outside every boundary root", host.display())) + })?; + let base = PathBuf::from(format!("{MOUNT_PREFIX}/{}/{index}", role_directory(role))); + Ok(if relative.as_os_str().is_empty() { + base + } else { + base.join(relative) + }) +} + +/// Returns the host path of a container path inside a writable root. +/// +/// Only writable roots map back: a build must never name an immutable or tool +/// file as its output. Dot segments are rejected rather than resolved. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `container` is not an absolute +/// path below `/boundary/writable/` with a canonical decimal index of +/// an existing writable root and only normal components after it. +pub(crate) fn host_path( + roots: &BoundaryRoots, + container: &Path, +) -> Result> { + // `components` silently drops `.`, doubled and trailing separators, so + // lexical normal form is checked against the original text. + if container.components().collect::().as_os_str() != container.as_os_str() { + return Err(docker_error("container path is not in lexical normal form")); + } + let prefix = Path::new(MOUNT_PREFIX).join(role_directory(RootRole::Writable)); + let relative = container.strip_prefix(&prefix).map_err(|_| { + docker_error(format!( + "{} is not below the writable mount prefix", + container.display() + )) + })?; + let mut components = relative.components(); + let index = components + .next() + .and_then(|component| match component { + std::path::Component::Normal(text) => text.to_str(), + _ => None, + }) + .filter(|text| { + text.bytes().all(|byte| byte.is_ascii_digit()) + && (*text == "0" || !text.starts_with('0')) + }) + .and_then(|text| text.parse::().ok()) + .ok_or_else(|| docker_error("container path has no canonical writable root index"))?; + let root = roots + .writable() + .get(index) + .ok_or_else(|| docker_error(format!("no writable root has index {index}")))?; + let mut host = root.clone(); + for component in components { + match component { + std::path::Component::Normal(part) => host.push(part), + _ => { + return Err(docker_error( + "container path has a dot segment after its root", + )); + } + } + } + Ok(host) +} + +/// Builds the bind mounts for `roots`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when a source path contains `,` or `=`, +/// which the `--mount` field syntax cannot carry unambiguously, or when any +/// system root is present: the container exposes only reviewed image tools, +/// never a host system directory. +pub(crate) fn mounts(roots: &BoundaryRoots) -> Result, Report> { + if !roots.system().is_empty() { + return Err(docker_error( + "the container boundary refuses host system roots", + )); + } + roots + .entries() + .map(|(role, _, source)| { + let text = validate_path_text(source)?; + if text.contains(',') || text.contains('=') { + return Err(docker_error(format!( + "mount source {text:?} contains ',' or '='" + ))); + } + Ok(Mount { + source: source.to_path_buf(), + target: container_path(roots, source)? + .to_string_lossy() + .into_owned(), + read_only: role != RootRole::Writable, + }) + }) + .collect() +} + +/// Everything one `docker run` invocation needs besides settings. +#[derive(Clone, Copy, Debug)] +pub(crate) struct RunPlan<'a> { + pub(crate) config: &'a Path, + pub(crate) seccomp: &'a Path, + pub(crate) name: &'a str, + pub(crate) image: &'a str, + pub(crate) mounts: &'a [Mount], + pub(crate) environment: &'a [(OsString, OsString)], + pub(crate) working_directory: &'a Path, + pub(crate) program: &'a Path, + pub(crate) arguments: &'a [OsString], + pub(crate) interactive: bool, + /// Mask applied before the program through [`SHELL`], when set. + pub(crate) umask: Option, +} + +fn mount_argument(mount: &Mount) -> OsString { + let mut value = OsString::from("type=bind,source="); + value.push(&mount.source); + value.push(",target="); + value.push(&mount.target); + if mount.read_only { + value.push(",readonly,bind-propagation=rprivate,bind-recursive=readonly"); + } + value +} + +/// Fixed isolation flags; deliberately no `--pid` (the default namespace is +/// already private) and no `--tty`. +const ISOLATION_ARGUMENTS: [&str; 12] = [ + "--init", + "--pull", + "never", + "--network", + "none", + "--ipc", + "none", + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges=true", +]; + +fn limit_arguments(settings: &DockerSettings) -> [String; 10] { + [ + String::from("--pids-limit"), + settings.pids_limit.to_string(), + String::from("--cpus"), + settings.cpus.to_string(), + String::from("--memory"), + format!("{}b", settings.memory_bytes), + String::from("--user"), + format!("{}:{}", settings.user.0, settings.user.1), + String::from("--tmpfs"), + format!("/tmp:ro,noexec,nosuid,nodev,size={}", settings.tmpfs_bytes), + ] +} + +/// Returns the fixed shell words that apply `umask` before `exec "$@"`. +/// +/// The script is built only from the validated octal digits; the program and +/// its arguments follow as positional parameters and are never interpolated. +fn umask_prefix(umask: Option) -> Vec { + umask + .map(|umask| { + [ + String::from(SHELL), + String::from("-c"), + format!("umask {umask} && exec \"$@\""), + String::from(SHELL_NAME), + ] + .into_iter() + .map(OsString::from) + .collect() + }) + .unwrap_or_default() +} + +/// Returns the complete fixed `docker create` argument vector for `plan`. +#[must_use] +pub(crate) fn create_arguments(settings: &DockerSettings, plan: &RunPlan<'_>) -> Vec { + let mut arguments = host_arguments(plan.config); + arguments.extend(["create", "--name", plan.name].iter().map(OsString::from)); + arguments.extend(ISOLATION_ARGUMENTS.iter().map(OsString::from)); + let mut seccomp = OsString::from("seccomp="); + seccomp.push(plan.seccomp); + arguments.extend([OsString::from("--security-opt"), seccomp]); + arguments.extend(limit_arguments(settings).into_iter().map(OsString::from)); + if plan.interactive { + arguments.push(OsString::from("--interactive")); + } + for mount in plan.mounts { + arguments.extend([OsString::from("--mount"), mount_argument(mount)]); + } + for (name, value) in plan.environment { + let mut assignment = name.clone(); + assignment.push("="); + assignment.push(value); + arguments.extend([OsString::from("--env"), assignment]); + } + arguments.extend([ + OsString::from("--workdir"), + plan.working_directory.as_os_str().to_os_string(), + OsString::from(plan.image), + ]); + arguments.extend(umask_prefix(plan.umask)); + arguments.push(plan.program.as_os_str().to_os_string()); + arguments.extend(plan.arguments.iter().cloned()); + arguments +} + +fn container_name() -> Result> { + Ok(format!( + "ts-release-boundary-{}", + hex::encode(super::random_bytes::<12>()?) + )) +} + +/// Folds `failures` into one report, or `Ok` when there are none. +fn aggregate(mut failures: Vec>) -> Result<(), Report> { + if failures.is_empty() { + return Ok(()); + } + let first = failures.remove(0); + Err(failures.into_iter().fold(first, |report, other| { + report.attach(format!("additional cleanup failure: {other:?}")) + })) +} + +/// A configured container boundary. +#[derive(Debug)] +pub(crate) struct DockerBoundary { + client: DockerClient, + settings: DockerSettings, + /// Content ID of the [`ImageSelection`] this boundary was configured with. + image_id: String, + mounts: Vec, +} + +impl DockerBoundary { + /// Validates settings, the daemon socket and mounts, then pins inputs. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for invalid settings, an unusable + /// daemon socket, an unpinned image, unmountable roots or host inputs + /// that overlap a mounted root, or an image with declared volumes. + pub(crate) fn new( + roots: &BoundaryRoots, + settings: DockerSettings, + image: ImageSelection, + state_parent: &Path, + ) -> Result> { + Self::configure(roots, settings, image.into_id(), state_parent) + } + + /// Configures a boundary for an image ID taken from an [`ImageSelection`]. + fn configure( + roots: &BoundaryRoots, + settings: DockerSettings, + image_id: String, + state_parent: &Path, + ) -> Result> { + settings.validate()?; + validate_image_id(&image_id)?; + check_daemon_socket(Path::new(DOCKER_SOCKET))?; + let mounts = mounts(roots)?; + let mounted: Vec<&Path> = mounts.iter().map(|mount| mount.source.as_path()).collect(); + let client = DockerClient::new( + &settings.client, + state_parent, + &mounted, + settings.host_limits, + )?; + client.require_no_image_volumes(&image_id)?; + Ok(Self { + client, + settings, + image_id, + mounts, + }) + } + + /// Configures a sibling container with the same image and settings over + /// `roots`, with fresh host inputs beside this boundary's inputs. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] as [`Self::new`] does, or when the + /// sibling pinned a different client or seccomp profile. + pub(crate) fn sibling(&self, roots: &BoundaryRoots) -> Result> { + let state_parent = self + .client + .inputs() + .directory() + .parent() + .ok_or_else(|| docker_error("Docker host input directory has no parent"))?; + let sibling = Self::configure( + roots, + self.settings.clone(), + self.image_id.clone(), + state_parent, + )?; + if sibling.client.inputs().digests() != self.client.inputs().digests() { + return Err(docker_error( + "sibling container pinned another Docker client or seccomp profile", + )); + } + Ok(sibling) + } + + /// Returns the configuration fingerprint (before the instance nonce). + #[must_use] + pub(crate) fn fingerprint(&self) -> [u8; 32] { + let plan = RunPlan { + config: self.client.inputs().config(), + seccomp: self.client.inputs().effective_profile(), + name: "", + image: &self.image_id, + mounts: &self.mounts, + environment: &[], + working_directory: Path::new("/"), + program: Path::new("/"), + arguments: &[], + interactive: false, + umask: None, + }; + let mut hasher = Sha256::new(); + hasher.update(FINGERPRINT_DOMAIN); + let digests = self.client.inputs().digests(); + let parts = create_arguments(&self.settings, &plan) + .into_iter() + .chain(digests.iter().map(OsString::from)); + for part in parts { + let bytes = part.as_encoded_bytes(); + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + hasher.finalize().into() + } + + /// Runs `command` in a fresh container, then always cleans it up. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for changed host inputs, a failed + /// daemon-socket check or failed cleanup, or [`ReleaseError::Process`] + /// when the client cannot be run. + pub(crate) fn run( + &self, + roots: &BoundaryRoots, + command: &ConfinedCommand, + ) -> Result> { + let name = container_name()?; + let working_directory = container_path(roots, &command.working_directory)?; + let program = container_path(roots, &command.program)?; + let plan = RunPlan { + config: self.client.inputs().config(), + seccomp: self.client.inputs().effective_profile(), + name: &name, + image: &self.image_id, + mounts: &self.mounts, + environment: &command.environment, + working_directory: &working_directory, + program: &program, + arguments: &command.arguments, + interactive: command.stdin.is_some(), + umask: command.umask, + }; + let arguments = create_arguments(&self.settings, &plan); + complete_created( + self.client.create(arguments, self.settings.host_limits), + |id| { + self.client + .command( + start_arguments(self.client.inputs().config(), id, command.stdin.is_some()), + command.limits, + ) + .and_then(|bounded| command.finish(bounded).run()) + }, + |id| self.cleanup(&name, id), + ) + } + + /// Attempts bounded stop, remove and a confirming listing independently, + /// and aggregates every failure, including a host-input integrity + /// failure observed during cleanup. + fn cleanup(&self, name: &str, id: Option<&ContainerId>) -> Result<(), Report> { + let grace = self.settings.stop_timeout_seconds.to_string(); + let (reference, filter) = cleanup_target(name, id); + let mut failures = Vec::new(); + // A stopped or already removed container can make stop or rm fail; + // the final listing confirms absence of the exact acknowledged ID. + // After failed creation, name absence never resolves the creation error. + let _ = self + .client + .cleanup_host(&["stop", "--time", &grace, reference], &mut failures); + let _ = self + .client + .cleanup_host(&["rm", "--force", reference], &mut failures); + let listing = self.client.cleanup_host( + &["ps", "--all", "--quiet", "--filter", &filter], + &mut failures, + ); + let gone = listing.as_ref().is_some_and(|listing| { + listing.outcome() == (ProcessOutcome::Exited { code: 0 }) && listing.stdout().is_empty() + }); + if !gone { + failures.push(docker_error(format!( + "container {reference} was not confirmed removed" + ))); + } + aggregate(failures) + } +} + +#[cfg(test)] +mod host_path_tests; + +/// Attaches to a confirmed container, preserving its configured stdin behavior. +#[must_use] +pub(crate) fn start_arguments(config: &Path, id: &ContainerId, interactive: bool) -> Vec { + let mut arguments = host_arguments(config); + arguments.extend([OsString::from("start"), OsString::from("--attach")]); + if interactive { + arguments.push(OsString::from("--interactive")); + } + arguments.push(OsString::from(id.as_str())); + arguments +} + +/// Starts only acknowledged creations and always attempts the matching cleanup. +/// +/// # Errors +/// +/// Returns the creation or start failure together with any cleanup failure. +/// Observing absence after failed creation never clears the creation error. +pub(crate) fn complete_created( + created: Result>, + start: impl FnOnce(&ContainerId) -> Result>, + cleanup: impl FnOnce(Option<&ContainerId>) -> Result<(), Report>, +) -> Result> { + let (outcome, cleanup) = match created { + Ok(id) => (start(&id), cleanup(Some(&id))), + Err(error) => (Err(error), cleanup(None)), + }; + match (outcome, cleanup) { + (Ok(result), Ok(())) => Ok(result), + (Ok(_), Err(cleanup)) => Err(cleanup), + (Err(error), Ok(())) => Err(error), + (Err(error), Err(cleanup)) => { + Err(error.attach(format!("cleanup also failed: {cleanup:?}"))) + } + } +} + +/// Selects ID-based cleanup after acknowledgment, or name lookup for unresolved creation. +pub(crate) fn cleanup_target<'a>(name: &'a str, id: Option<&'a ContainerId>) -> (&'a str, String) { + match id { + Some(id) => (id.as_str(), format!("id={}", id.as_str())), + None => (name, format!("name=^/{name}$")), + } +} diff --git a/crates/trusted-server-release/src/boundary/docker/host_path_tests.rs b/crates/trusted-server-release/src/boundary/docker/host_path_tests.rs new file mode 100644 index 000000000..5523ac7a3 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/docker/host_path_tests.rs @@ -0,0 +1,85 @@ +//! Pure tests of the container-to-host path inverse; no process is started. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::*; + +struct Layout { + _directory: tempfile::TempDir, + roots: BoundaryRoots, + base: PathBuf, +} + +fn layout() -> Layout { + let directory = tempfile::tempdir().expect("should create layout directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize layout"); + for name in ["source", "tools", "work-a", "work-b"] { + fs::create_dir(base.join(name)).expect("should create root directory"); + } + let roots = BoundaryRoots::new( + &[base.join("source")], + &[base.join("tools")], + &[base.join("work-a"), base.join("work-b")], + ) + .expect("should build roots"); + Layout { + _directory: directory, + roots, + base, + } +} + +#[test] +fn writable_container_paths_map_back_to_their_host_roots() { + let layout = layout(); + for (name, container) in [ + ("work-a", "/boundary/writable/0"), + ("work-b", "/boundary/writable/1"), + ] { + let host = layout.base.join(name); + assert_eq!( + host_path(&layout.roots, Path::new(container)).expect("should map the root itself"), + host, + "should map {container} to its root" + ); + let nested = layout.base.join(name).join("release/example.wasm"); + let forward = container_path(&layout.roots, &nested).expect("should map forward"); + assert_eq!( + host_path(&layout.roots, &forward).expect("should map the nested file back"), + nested, + "should invert container_path for {name}" + ); + } +} + +#[test] +fn immutable_tool_unknown_and_malformed_container_paths_are_rejected() { + let layout = layout(); + for text in [ + "/boundary/immutable/0/file", + "/boundary/tool/0/bin/cargo", + "/boundary/writable/2/file", + "/boundary/writable/x/file", + "/boundary/writable/00/file", + "/boundary/writable/+1/file", + "/boundary/writable/+0/file", + "/boundary/writable/-0/file", + "/boundary/writable/1e0/file", + "/boundary/writable/1/file", + "/boundary/writable/0/../1/file", + "/boundary/writable/0/./file", + "/boundary/writable", + "/boundary/writable/", + "/boundaryx/writable/0/file", + "/boundary/writable/0file", + "boundary/writable/0/file", + "/etc/passwd", + "", + ] { + assert!( + host_path(&layout.roots, Path::new(text)).is_err(), + "should reject container path {text:?}" + ); + } +} diff --git a/crates/trusted-server-release/src/boundary/entropy.rs b/crates/trusted-server-release/src/boundary/entropy.rs new file mode 100644 index 000000000..ee660907d --- /dev/null +++ b/crates/trusted-server-release/src/boundary/entropy.rs @@ -0,0 +1,79 @@ +//! Exact, bounded reads from the kernel entropy source. + +use std::fs::File; +use std::io::Read; + +use error_stack::Report; + +use super::boundary_error; +use crate::error::ReleaseError; + +/// Returns `N` bytes from the kernel random source. +/// +/// # Errors +/// +/// Rejects inaccessible entropy, read failures and short reads without a fallback. +pub(crate) fn random_bytes() -> Result<[u8; N], Report> { + let source = File::open("/dev/urandom") + .map_err(|error| boundary_error(format!("cannot open kernel randomness: {error}")))?; + read_entropy(source) +} + +fn read_entropy(source: impl Read) -> Result<[u8; N], Report> { + let limit = + u64::try_from(N).map_err(|_| boundary_error("entropy request exceeds read limit"))?; + let mut bytes = Vec::new(); + source + .take(limit) + .read_to_end(&mut bytes) + .map_err(|error| boundary_error(format!("cannot read kernel randomness: {error}")))?; + // Array conversion errors contain the bytes, so never attach the error itself. + bytes + .try_into() + .map_err(|_| boundary_error("kernel randomness returned fewer bytes than requested")) +} + +#[cfg(test)] +mod tests { + use std::io::{self, Cursor}; + + use super::*; + + struct FailedRead; + + impl Read for FailedRead { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("example read failure")) + } + } + + #[test] + fn entropy_read_preserves_source_bytes_and_does_not_consume_past_limit() { + let mut source = Cursor::new(b"example entropy bytes"); + let bytes = read_entropy::<7>(&mut source).expect("should read the requested bytes"); + assert_eq!(bytes, *b"example", "should preserve source bytes exactly"); + assert_eq!(source.position(), 7, "should stop at the requested limit"); + } + + #[test] + fn entropy_read_rejects_short_input_without_disclosing_bytes() { + let report = read_entropy::<32>(&b"example partial entropy"[..]) + .expect_err("should reject a short entropy source"); + let text = format!("{report:?}"); + assert!(text.contains("fewer bytes"), "should identify a short read"); + assert!( + !text.contains("example partial entropy"), + "should not disclose partial entropy" + ); + } + + #[test] + fn entropy_read_rejects_io_failure() { + let source = io::repeat(1).take(1).chain(FailedRead); + let report = read_entropy::<32>(source).expect_err("should reject a read failure"); + assert!( + format!("{report:?}").contains("cannot read kernel randomness"), + "should identify the entropy read failure" + ); + } +} diff --git a/crates/trusted-server-release/src/boundary/host_inputs.rs b/crates/trusted-server-release/src/boundary/host_inputs.rs new file mode 100644 index 000000000..1c92f2d40 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/host_inputs.rs @@ -0,0 +1,312 @@ +//! Host-owned Docker client inputs pinned for the life of one boundary. +//! +//! The reviewed seccomp baseline, the derived effective profile and an empty +//! Docker client configuration directory live in a fresh private directory +//! that is disjoint from every mounted root (checked before anything is +//! created). The two profiles, the fixed client executable and the empty +//! configuration are rehashed before every Docker host command, so a change +//! between commands rejects the run instead of silently applying. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; +use tempfile::TempDir; + +use super::roots::{overlaps, validate_path_text}; +use super::seccomp; +use crate::error::ReleaseError; + +const BASELINE_NAME: &str = "seccomp-baseline.json"; +const EFFECTIVE_NAME: &str = "seccomp-effective.json"; +const CONFIG_NAME: &str = "docker-config"; +const READ_ONLY_FILE: u32 = 0o444; +const READ_ONLY_DIRECTORY: u32 = 0o500; +const HASH_CHUNK: usize = 64 * 1024; + +fn input_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Hashes a regular file without following a final symlink. +fn hash_regular(path: &Path) -> Result> { + let mut file = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|error| input_error(format!("cannot open {}: {error}", path.display())))?; + let metadata = file + .metadata() + .map_err(|error| input_error(format!("cannot stat {}: {error}", path.display())))?; + if !metadata.is_file() { + return Err(input_error(format!( + "{} is not a regular file", + path.display() + ))); + } + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; HASH_CHUNK]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| input_error(format!("cannot read {}: {error}", path.display())))?; + let Some(chunk) = buffer.get(..read).filter(|chunk| !chunk.is_empty()) else { + return Ok(hex::encode(hasher.finalize())); + }; + hasher.update(chunk); + } +} + +/// A file whose digest must not change. +#[derive(Debug)] +struct PinnedFile { + path: PathBuf, + sha256: String, +} + +impl PinnedFile { + fn verify(&self) -> Result<(), Report> { + if hash_regular(&self.path)? != self.sha256 { + return Err(input_error(format!( + "pinned Docker input {} changed", + self.path.display() + ))); + } + Ok(()) + } +} + +/// Identity of the empty configuration directory. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DirectoryIdentity { + device: u64, + inode: u64, + mode: u32, +} + +fn directory_identity(path: &Path) -> Result> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| input_error(format!("cannot inspect {}: {error}", path.display())))?; + if !metadata.is_dir() { + return Err(input_error(format!( + "{} is not a directory", + path.display() + ))); + } + Ok(DirectoryIdentity { + device: metadata.dev(), + inode: metadata.ino(), + mode: metadata.mode(), + }) +} + +fn entry_names(path: &Path) -> Result, Report> { + let list_error = + |error: std::io::Error| input_error(format!("cannot list {}: {error}", path.display())); + let mut names = BTreeSet::new(); + for entry in fs::read_dir(path).map_err(list_error)? { + names.insert(entry.map_err(list_error)?.file_name()); + } + Ok(names) +} + +fn write_read_only(path: &Path, bytes: &[u8]) -> Result> { + let write_error = + |error: std::io::Error| input_error(format!("cannot write {}: {error}", path.display())); + let mut file = File::options() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .map_err(write_error)?; + file.write_all(bytes).map_err(write_error)?; + file.sync_all().map_err(write_error)?; + fs::set_permissions(path, fs::Permissions::from_mode(READ_ONLY_FILE)).map_err(write_error)?; + let pinned = PinnedFile { + path: path.to_path_buf(), + sha256: seccomp::sha256_hex(bytes), + }; + pinned.verify()?; + Ok(pinned) +} + +/// Rejects a state parent that overlaps any mounted root. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `parent` is invalid, missing, or +/// equal to, inside or above any of `mounted`. +pub(crate) fn check_disjoint( + parent: &Path, + mounted: &[&Path], +) -> Result> { + validate_path_text(parent)?; + let canonical = fs::canonicalize(parent) + .map_err(|error| input_error(format!("cannot resolve Docker state parent: {error}")))?; + validate_path_text(&canonical)?; + if let Some(root) = mounted.iter().find(|root| overlaps(&canonical, root)) { + return Err(input_error(format!( + "Docker host inputs at {} overlap mounted root {}", + canonical.display(), + root.display() + ))); + } + Ok(canonical) +} + +/// Pinned host-owned inputs for every Docker client invocation. +#[derive(Debug)] +pub(crate) struct HostInputs { + directory: TempDir, + client: PinnedFile, + baseline: PinnedFile, + effective: PinnedFile, + config: PathBuf, + config_identity: DirectoryIdentity, +} + +impl HostInputs { + /// Scans, then creates the inputs in a fresh directory under `parent`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when `parent` overlaps a mounted + /// root, `client` is not a canonical regular file, the seccomp baseline + /// fails its reviewed digest, or any input cannot be written and pinned. + pub(crate) fn create( + parent: &Path, + client: &Path, + mounted: &[&Path], + ) -> Result> { + let parent = check_disjoint(parent, mounted)?; + validate_path_text(client)?; + let canonical_client = fs::canonicalize(client) + .map_err(|error| input_error(format!("cannot resolve Docker client: {error}")))?; + if canonical_client != client { + return Err(input_error( + "Docker client path must be canonical, not an alias", + )); + } + if mounted.iter().any(|root| overlaps(&canonical_client, root)) { + return Err(input_error( + "Docker client must be outside every mounted root", + )); + } + let client = PinnedFile { + sha256: hash_regular(client)?, + path: canonical_client, + }; + let directory = tempfile::Builder::new() + .prefix("ts-boundary-host-") + .tempdir_in(&parent) + .map_err(|error| input_error(format!("cannot create Docker host inputs: {error}")))?; + if !entry_names(directory.path())?.is_empty() { + return Err(input_error( + "fresh Docker host input directory is not empty", + )); + } + let baseline_bytes = seccomp::baseline()?; + let baseline = write_read_only(&directory.path().join(BASELINE_NAME), baseline_bytes)?; + let effective_bytes = seccomp::effective(baseline_bytes)?; + let effective = write_read_only(&directory.path().join(EFFECTIVE_NAME), &effective_bytes)?; + let config = directory.path().join(CONFIG_NAME); + fs::create_dir(&config) + .and_then(|()| { + fs::set_permissions(&config, fs::Permissions::from_mode(READ_ONLY_DIRECTORY)) + }) + .map_err(|error| input_error(format!("cannot create empty Docker config: {error}")))?; + let config_identity = directory_identity(&config)?; + let inputs = Self { + directory, + client, + baseline, + effective, + config, + config_identity, + }; + inputs.verify()?; + Ok(inputs) + } + + /// Rehashes every input; called before every Docker host command. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the client or either profile + /// changed, the configuration directory was replaced or is not empty, or + /// the input directory gained or lost an entry. + pub(crate) fn verify(&self) -> Result<(), Report> { + self.verify_control()?; + self.baseline.verify()?; + self.effective.verify()?; + Ok(()) + } + + /// Verifies the inputs used by every host operation, including cleanup. + /// + /// Cleanup does not consume seccomp profiles, so it may still stop a + /// container after profile tampering. It must never execute a changed + /// client or consume a changed configuration. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when a control input changed. + pub(crate) fn verify_control(&self) -> Result<(), Report> { + self.client.verify()?; + if directory_identity(&self.config)? != self.config_identity { + return Err(input_error("empty Docker config directory was replaced")); + } + if !entry_names(&self.config)?.is_empty() { + return Err(input_error("Docker config directory is no longer empty")); + } + let expected: BTreeSet = [BASELINE_NAME, EFFECTIVE_NAME, CONFIG_NAME] + .into_iter() + .map(OsString::from) + .collect(); + if entry_names(self.directory.path())? != expected { + return Err(input_error( + "Docker host input directory gained or lost entries", + )); + } + Ok(()) + } + + /// Returns the private host input directory. + #[must_use] + pub(crate) fn directory(&self) -> &Path { + self.directory.path() + } + + /// Returns the pinned client executable. + #[must_use] + pub(crate) fn client(&self) -> &Path { + &self.client.path + } + + /// Returns the empty client configuration directory. + #[must_use] + pub(crate) fn config(&self) -> &Path { + &self.config + } + + /// Returns the effective seccomp profile path. + #[must_use] + pub(crate) fn effective_profile(&self) -> &Path { + &self.effective.path + } + + /// Returns the client, baseline and effective digests, in that order. + #[must_use] + pub(crate) fn digests(&self) -> [&str; 3] { + [ + &self.client.sha256, + &self.baseline.sha256, + &self.effective.sha256, + ] + } +} diff --git a/crates/trusted-server-release/src/boundary/macos.rs b/crates/trusted-server-release/src/boundary/macos.rs new file mode 100644 index 000000000..fe920d9cd --- /dev/null +++ b/crates/trusted-server-release/src/boundary/macos.rs @@ -0,0 +1,389 @@ +//! macOS `sandbox-exec` deny-default profile. +//! +//! The profile text is assembled only from compiled-in fragments and +//! generated slot indices; every path reaches the sandbox as a `-D NAME=PATH` +//! define read through `(param "NAME")`, never as profile text. A missing +//! define makes `sandbox-exec` reject the profile, so slots fail closed. +//! +//! The base rules follow the observed working deny-default profile that ran +//! offline `npm ci` and the full Vitest suite under denial: system library +//! reads from explicit `/usr` subdirectories (never `/usr/local`), +//! `same-sandbox` signals only, file writes only under private writable roots +//! and `(deny network*)`, after which only Unix-domain sockets whose paths lie +//! under a writable root are re-allowed. `connect` to an INET address from +//! the confined process and every descendant fails with `EPERM`. +//! +//! Observed on macOS 26.6.2: under `(deny default)` alone the `setsid` and +//! `setpgid` system calls are not filtered; `(allow syscall-unix)` followed by +//! `(deny syscall-unix (syscall-number …))` makes both fail with `EPERM` in +//! the confined process and its descendants. +//! +//! System roots, such as the Apple developer directory, are allowed only +//! `file-read*` and `process-exec` and carry the same explicit `file-write*` +//! denial as immutable and tool roots. +//! +//! `xcrun`, `xcodebuild`, `xcode-select`, `sudo`, `su`, `login` and +//! `newgrp` in `/usr/bin` may not be executed. A build script that invokes +//! `xcrun` (for example to locate an SDK) therefore fails closed; the recipe +//! supplies `SDKROOT`, `DEVELOPER_DIR` and the direct Xcode `clang` and `ar` +//! instead. The `/usr/bin` `cc`, `clang` and `ld` shims are not denied. +//! +//! Each run also binds two fresh empty marker files, created outside every +//! root: the profile allows reading one and denies reading the other. That +//! pair of rules identifies processes of exactly this run's sandbox instance +//! to the survivor check, independent of their environment. + +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::ConfinedCommand; +use super::roots::{BoundaryRoots, RootRole, validate_path_text}; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessResult}; + +/// The fixed system `sandbox-exec` executable. +pub(crate) const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; +/// Environment variable carrying the per-run survivor-detection nonce. +pub(crate) const RUN_NONCE_VARIABLE: &str = "TS_RELEASE_BOUNDARY_RUN"; +/// Darwin `SYS_setpgid` (``), identical on `arm64` and `x86_64`. +pub(crate) const SYS_SETPGID: u32 = 82; +/// Darwin `SYS_setsid` (``), identical on `arm64` and `x86_64`. +pub(crate) const SYS_SETSID: u32 = 147; + +/// Deny-default base rules; contains no caller-supplied text. +const PROFILE_HEADER: &str = r#"(version 1) +(deny default) +(allow process-fork) +(allow signal (target same-sandbox)) +(allow sysctl-read) +(allow syscall-unix) +(deny syscall-unix (syscall-number 147 82)) +(allow file-read-metadata) +(allow file-read* + (literal "/") + (subpath "/usr/bin") + (subpath "/usr/lib") + (subpath "/usr/libexec") + (subpath "/usr/share") + (subpath "/bin") + (subpath "/System") + (subpath "/Library/Apple") + (subpath "/private/var/db/dyld") + (subpath "/dev") + (literal "/private/var/select/sh")) +(allow process-exec + (subpath "/usr/bin") + (subpath "/usr/libexec") + (subpath "/bin") + (subpath "/System")) +(allow file-read* file-write* (literal "/dev/null")) +(allow file-ioctl (literal "/dev/null")) +"#; +/// Network denial; later SBPL rules take precedence over earlier ones. +const NETWORK_DENIAL: &str = "(deny network*)\n"; +/// Cargo's macOS `LibreSSL` initialization ignores `OPENSSL_CONF` and exits on +/// EPERM for its optional system configuration. Hide only that ambient input +/// as absent instead of granting it access. Apply after every root read grant. +const OPENSSL_CONFIGURATION_DENIAL: &str = "(deny file-read* (with errno ENOENT)\n \ + (literal \"/private/etc/ssl/openssl.cnf\")\n \ + (literal \"/etc/ssl/openssl.cnf\"))\n"; +/// Tools no build step may start, even though `/usr/bin` is executable: the +/// Xcode selection shims (`xcrun`, `xcodebuild`, `xcode-select`), which +/// would pick a developer directory outside the bound record, and the +/// privilege tools. The `/usr/bin` compiler and linker shims stay allowed: +/// the recipe selects the bound Xcode tools directly, and the shims honour +/// the bound `DEVELOPER_DIR` if a build script runs them anyway. +pub(crate) const DENIED_EXECUTABLES: [&str; 7] = [ + "/usr/bin/sudo", + "/usr/bin/su", + "/usr/bin/login", + "/usr/bin/newgrp", + "/usr/bin/xcrun", + "/usr/bin/xcodebuild", + "/usr/bin/xcode-select", +]; + +/// One explicit `process-exec` denial for a fixed compiled-in path. +fn execution_denial(path: &str) -> String { + format!("(deny process-exec (literal \"{path}\"))\n") +} +/// Profile parameter naming the run marker the instance may read. +pub(crate) const RUN_MARK_ALLOW: &str = "RUN_MARK_ALLOW"; +/// Profile parameter naming the run marker the instance must not read. +pub(crate) const RUN_MARK_DENY: &str = "RUN_MARK_DENY"; +/// Instance-identifying marker rules, last so that they take precedence. +const RUN_MARK_RULES: &str = "(allow file-read-data (literal (param \"RUN_MARK_ALLOW\")))\n\ + (deny file-read* (literal (param \"RUN_MARK_DENY\")))\n"; +/// Allowance before a run's recorded start. It only widens eligibility; +/// identity checks still gate every match. +const START_MARGIN: Duration = Duration::from_secs(1); +/// Profile identity domain for the configuration fingerprint. +const FINGERPRINT_DOMAIN: &[u8] = b"trusted-server-boundary-macos-sbpl-v2\n"; + +/// A generated profile and the defines that bind its slots. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MacProfile { + text: String, + defines: Vec<(String, String)>, +} + +fn slot_name(role: RootRole, index: usize) -> String { + let prefix = match role { + RootRole::Immutable => "IMMUTABLE", + RootRole::Tool => "TOOL", + RootRole::Writable => "WRITABLE", + RootRole::System => "SYSTEM", + }; + format!("{prefix}_{index}") +} + +fn allow_rule(role: RootRole, name: &str) -> String { + let operations = match role { + RootRole::Immutable | RootRole::Tool | RootRole::System => "file-read* process-exec", + RootRole::Writable => "file-read* file-write* process-exec", + }; + format!("(allow {operations} (subpath (param \"{name}\")))\n") +} + +fn deny_write_rule(name: &str) -> String { + format!("(deny file-write* (subpath (param \"{name}\")))\n") +} + +fn unix_socket_rule(name: &str) -> String { + format!( + "(allow network-bind network-outbound network-inbound \ + (local unix-socket (subpath (param \"{name}\"))) \ + (remote unix-socket (subpath (param \"{name}\"))))\n" + ) +} + +/// Validates one define; the value must be safe profile-parameter text. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `path` fails +/// [`validate_path_text`]. +pub(crate) fn define(name: &str, path: &Path) -> Result<(String, String), Report> { + let value = validate_path_text(path)?; + Ok((name.to_owned(), value.to_owned())) +} + +/// Returns 16 random bytes as lowercase hex. +fn run_nonce() -> Result> { + Ok(hex::encode(super::random_bytes::<16>()?)) +} + +impl MacProfile { + /// Generates the profile for `roots`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when a root path is not safe define + /// text. + pub(crate) fn generate(roots: &BoundaryRoots) -> Result> { + let mut text = String::from(PROFILE_HEADER); + let mut denials = String::new(); + let mut sockets = String::new(); + let mut defines = Vec::new(); + for (role, index, path) in roots.entries() { + let name = slot_name(role, index); + text.push_str(&allow_rule(role, &name)); + if role == RootRole::Writable { + sockets.push_str(&unix_socket_rule(&name)); + } else { + denials.push_str(&deny_write_rule(&name)); + } + defines.push(define(&name, path)?); + } + text.push_str(&denials); + text.push_str(OPENSSL_CONFIGURATION_DENIAL); + for path in DENIED_EXECUTABLES { + text.push_str(&execution_denial(path)); + } + text.push_str(NETWORK_DENIAL); + text.push_str(&sockets); + text.push_str(RUN_MARK_RULES); + Ok(Self { text, defines }) + } + + /// Returns the profile text. + #[must_use] + pub(crate) fn text(&self) -> &str { + &self.text + } + + /// Returns the `-D` defines in slot order. + #[must_use] + pub(crate) fn defines(&self) -> &[(String, String)] { + &self.defines + } + + /// Returns `sandbox-exec` arguments preceding the confined program. + #[must_use] + pub(crate) fn arguments(&self) -> Vec { + let mut arguments = vec![OsString::from("-p"), OsString::from(&self.text)]; + for (name, value) in &self.defines { + arguments.push(OsString::from("-D")); + arguments.push(OsString::from(format!("{name}={value}"))); + } + arguments + } + + /// Returns the profile fingerprint (before the instance nonce). The marker + /// rules are covered; per-run marker paths are not. + #[must_use] + pub(crate) fn fingerprint(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(FINGERPRINT_DOMAIN); + for argument in self.arguments() { + let bytes = argument.as_encoded_bytes(); + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + hasher.finalize().into() + } + + /// Wraps `command` in `sandbox-exec`, adding the run's marker defines and + /// nonce variable. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] when the bounded command rejects the + /// working directory or an environment variable (including a caller + /// variable named [`RUN_NONCE_VARIABLE`]). + pub(crate) fn wrap( + &self, + command: &ConfinedCommand, + run: &RunIdentity, + ) -> Result> { + let mut arguments = self.arguments(); + for (name, value) in &run.defines { + arguments.push(OsString::from("-D")); + arguments.push(OsString::from(format!("{name}={value}"))); + } + arguments.push(command.program.clone().into_os_string()); + arguments.extend(command.arguments.iter().cloned()); + let mut bounded = BoundedCommand::new( + Path::new(SANDBOX_EXEC), + &command.working_directory, + command.limits, + )? + .arguments(arguments) + .environment(RUN_NONCE_VARIABLE, &run.nonce)?; + if let Some(umask) = command.umask { + bounded = bounded.umask(umask); + } + for (name, value) in &command.environment { + bounded = if command.is_tool_selector(name) { + bounded.tool_selector_environment(name, value)? + } else { + bounded.environment(name, value)? + }; + } + Ok(command.finish(bounded)) + } + + /// Runs `command` confined, then kills and rejects any process of this + /// run's sandbox instance, or carrying its nonce, that outlived it. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the run identity cannot be + /// created or an escaped descendant was found (even if the run itself + /// succeeded), or the run's own error. + pub(crate) fn run( + &self, + command: &ConfinedCommand, + ) -> Result> { + let run = RunIdentity::new(self)?; + let outcome = self.wrap(command, &run).and_then(|bounded| bounded.run()); + let survivors = reap_survivors(&run); + match (outcome, survivors) { + (Ok(result), Ok(())) => Ok(result), + (Err(run), Ok(())) => Err(run), + (Ok(_), Err(survivors)) => Err(survivors), + (Err(run), Err(survivors)) => { + Err(survivors.attach(format!("the run also failed: {run:?}"))) + } + } + } +} + +/// One run's survivor identity: an environment nonce and two private marker +/// files outside every boundary root. +pub(crate) struct RunIdentity { + nonce: String, + started_after_micros: u64, + defines: [(String, String); 2], + allow: PathBuf, + deny: PathBuf, + _directory: tempfile::TempDir, +} + +impl RunIdentity { + /// Creates fresh markers for one run of `profile`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the markers cannot be created, + /// are not safe define text, or overlap a boundary root. + pub(crate) fn new(profile: &MacProfile) -> Result> { + let directory = tempfile::Builder::new() + .prefix("ts-boundary-run-") + .tempdir() + .map_err(super::boundary_error)?; + let base = fs::canonicalize(directory.path()).map_err(super::boundary_error)?; + if profile.defines.iter().any(|(_, root)| { + let root = Path::new(root); + base.starts_with(root) || root.starts_with(&base) + }) { + return Err(super::boundary_error("run markers overlap a boundary root")); + } + let allow = base.join("allow"); + let deny = base.join("deny"); + for marker in [&allow, &deny] { + fs::write(marker, b"").map_err(super::boundary_error)?; + } + Ok(Self { + nonce: run_nonce()?, + started_after_micros: started_after_micros()?, + defines: [ + define(RUN_MARK_ALLOW, &allow)?, + define(RUN_MARK_DENY, &deny)?, + ], + allow, + deny, + _directory: directory, + }) + } +} + +/// Returns the earliest process start this run may own: now, less a +/// [`START_MARGIN`], in microseconds since the epoch. +fn started_after_micros() -> Result> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(super::boundary_error)?; + u64::try_from(now.saturating_sub(START_MARGIN).as_micros()).map_err(super::boundary_error) +} + +#[cfg(target_os = "macos")] +fn reap_survivors(run: &RunIdentity) -> Result<(), Report> { + super::survivors::reap(&super::survivors::RunSignature { + variable: RUN_NONCE_VARIABLE, + nonce: &run.nonce, + allow: &run.allow, + deny: &run.deny, + started_after_micros: run.started_after_micros, + }) +} + +#[cfg(not(target_os = "macos"))] +fn reap_survivors(_run: &RunIdentity) -> Result<(), Report> { + Err(Report::new(ReleaseError::Boundary).attach("sandbox-exec survivor checks are macOS-only")) +} diff --git a/crates/trusted-server-release/src/boundary/probe.rs b/crates/trusted-server-release/src/boundary/probe.rs new file mode 100644 index 000000000..a3992c885 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/probe.rs @@ -0,0 +1,290 @@ +//! Loopback TCP socket probe and the listener that observes it. +//! +//! The probe makes exactly one TCP `connect` to a loopback `IP:PORT` and +//! reports either `connected` or `denied errno=` with the operating +//! system's real errno. It never synthesizes an errno: a failure without one +//! is an error, not a report. `socket()` success is not evidence (macOS +//! allows `socket(AF_INET)` under `deny default`), so only `connect` against a +//! listener the unconfined control actually reached counts. + +use std::ffi::OsString; +use std::io::{self, ErrorKind}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use crate::error::ReleaseError; + +/// Exit status of a probe whose connect was refused by the operating system. +pub(crate) const DENIED_STATUS: u8 = 3; +/// `boundary-probe` subcommand arguments preceding the address. +const RELEASE_PROBE_ARGUMENTS: [&str; 2] = ["boundary-probe", "connect"]; + +/// The single-line result of one probe connect. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum ProbeReport { + /// The TCP handshake completed. + #[display("connected")] + Connected, + /// `connect` failed with the operating system's `errno`. + #[display("denied errno={errno}")] + Denied { errno: i32 }, +} + +impl ProbeReport { + /// Parses exactly one report line terminated by a newline. + #[must_use] + pub(crate) fn parse_line(output: &[u8]) -> Option { + let line = std::str::from_utf8(output.strip_suffix(b"\n")?).ok()?; + if line == "connected" { + return Some(Self::Connected); + } + let digits = line.strip_prefix("denied errno=")?; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + digits.parse().ok().map(|errno| Self::Denied { errno }) + } +} + +fn boundary_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Parses a literal loopback `IP:PORT` with a nonzero port. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Usage`] for anything other than a literal IPv4 or +/// IPv6 loopback socket address with a nonzero port; host names are never +/// resolved. +pub(crate) fn parse_loopback(address: &str) -> Result> { + let parsed: SocketAddr = address.parse().map_err(|_| { + Report::new(ReleaseError::Usage).attach("probe address is not a literal IP:PORT") + })?; + if !parsed.ip().is_loopback() || parsed.port() == 0 { + return Err(Report::new(ReleaseError::Usage) + .attach("probe address must be loopback with a nonzero port")); + } + Ok(parsed) +} + +/// Attempts one TCP connect and reports the real outcome. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `connect` fails without an +/// operating-system errno; no errno is invented for it. +pub(crate) fn connect_probe(address: SocketAddr) -> Result> { + match TcpStream::connect(address) { + Ok(_) => Ok(ProbeReport::Connected), + Err(error) => match error.raw_os_error() { + Some(errno) => Ok(ProbeReport::Denied { errno }), + None => Err(boundary_error(format!( + "probe connect failed without an errno: {error}" + ))), + }, + } +} + +/// How a probe program frames its report on stdout. +#[derive(Clone, Copy, Debug)] +enum ProbeOutput { + /// Stdout is exactly one report line. + Exact, + /// Stdout carries a harness preamble, then the sentinel, then the line. + #[cfg(test)] + AfterSentinel(&'static [u8]), +} + +/// An executable that speaks the probe protocol: the final argument is the +/// loopback address, stdout is the report, exit `0` means connected and +/// [`DENIED_STATUS`] means denied. +#[derive(Clone, Debug)] +pub(crate) struct ProbeProgram { + program: PathBuf, + prefix: Vec, + environment: Vec<(OsString, OsString)>, + output: ProbeOutput, +} + +impl ProbeProgram { + /// Selects an ordinary CI copy of the source-built producer's fixed probe. + /// + /// # Errors + /// + /// Returns an error when the staged executable cannot be resolved. This + /// test-only selector supplies no production tool authentication. + #[cfg(test)] + pub(crate) fn from_staged_release_binary(path: &Path) -> Result> { + let program = + std::fs::canonicalize(path).map_err(|error| boundary_error(error.to_string()))?; + Ok(Self { + program, + prefix: RELEASE_PROBE_ARGUMENTS.iter().map(OsString::from).collect(), + environment: Vec::new(), + output: ProbeOutput::Exact, + }) + } + /// Selects this release binary's documented `boundary-probe connect` + /// subcommand. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the running executable cannot + /// be located or canonicalized. + pub(crate) fn release_binary() -> Result> { + let program = std::env::current_exe() + .and_then(std::fs::canonicalize) + .map_err(|error| boundary_error(format!("cannot locate release binary: {error}")))?; + Ok(Self { + program, + prefix: RELEASE_PROBE_ARGUMENTS.iter().map(OsString::from).collect(), + environment: Vec::new(), + output: ProbeOutput::Exact, + }) + } + + /// Selects a test-binary helper speaking the same protocol after a + /// harness preamble ending in `sentinel`. + #[cfg(test)] + pub(super) fn test_helper( + program: PathBuf, + prefix: Vec, + environment: Vec<(OsString, OsString)>, + sentinel: &'static [u8], + ) -> Self { + Self { + program, + prefix, + environment, + output: ProbeOutput::AfterSentinel(sentinel), + } + } + + /// Copies the executable into `directory` (a tool root) as a private + /// single-link, read-only file and returns the probe pointing at it. + /// + /// Cargo hard-links its build outputs, and tool roots require + /// `nlink == 1`, so a probe is never run from `target/` directly. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the copy cannot be created + /// (including when the destination already exists) or made read-only. + pub(crate) fn staged(&self, directory: &Path) -> Result> { + let name = self + .program + .file_name() + .ok_or_else(|| boundary_error("probe program has no file name"))?; + let directory = std::fs::canonicalize(directory) + .map_err(|error| boundary_error(format!("cannot resolve tool directory: {error}")))?; + let destination = directory.join(name); + if destination.exists() { + return Err(boundary_error("staged probe destination already exists")); + } + std::fs::copy(&self.program, &destination) + .and_then(|_| { + std::fs::set_permissions(&destination, std::fs::Permissions::from_mode(0o555)) + }) + .map_err(|error| boundary_error(format!("cannot stage probe: {error}")))?; + Ok(Self { + program: destination, + ..self.clone() + }) + } + + /// Returns the probe executable path. + pub(super) fn program(&self) -> &Path { + &self.program + } + + /// Returns the fixed environment the probe needs. + pub(super) fn environment(&self) -> &[(OsString, OsString)] { + &self.environment + } + + /// Returns the argument vector targeting `address`. + pub(super) fn arguments(&self, address: SocketAddr) -> Vec { + let mut arguments = self.prefix.clone(); + arguments.push(OsString::from(address.to_string())); + arguments + } + + /// Parses the probe's stdout under its framing. + pub(super) fn parse(&self, stdout: &[u8]) -> Option { + match self.output { + ProbeOutput::Exact => ProbeReport::parse_line(stdout), + #[cfg(test)] + ProbeOutput::AfterSentinel(sentinel) => { + let start = stdout + .windows(sentinel.len()) + .rposition(|window| window == sentinel)?; + ProbeReport::parse_line(stdout.get(start + sentinel.len()..)?) + } + } + } +} + +/// A non-blocking IPv4 loopback listener that counts completed connections. +pub(crate) struct LoopbackListener { + listener: TcpListener, + address: SocketAddr, +} + +impl LoopbackListener { + /// Binds an ephemeral port on `127.0.0.1`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the listener cannot be bound or + /// made non-blocking. + pub(crate) fn bind() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .and_then(|listener| { + listener.set_nonblocking(true)?; + Ok(listener) + }) + .map_err(|error| boundary_error(format!("cannot bind control listener: {error}")))?; + let address = listener + .local_addr() + .map_err(|error| boundary_error(format!("cannot read listener address: {error}")))?; + Ok(Self { listener, address }) + } + + /// Returns the bound loopback address. + #[must_use] + pub(crate) fn address(&self) -> SocketAddr { + self.address + } + + /// Accepts every queued connection and returns how many there were. + /// + /// A probe's handshake completes before it exits, so after its process + /// is reaped any connection it made is already queued. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when `accept` fails with anything + /// other than would-block or interruption. + pub(crate) fn drain_accepts(&self) -> Result> { + let mut accepted = 0; + loop { + match self.listener.accept() { + Ok(_) => accepted += 1, + Err(error) if error.kind() == ErrorKind::WouldBlock => return Ok(accepted), + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(accept_error(&error)), + } + } + } +} + +fn accept_error(error: &io::Error) -> Report { + boundary_error(format!( + "control listener accept failed (errno {:?}): {error}", + error.raw_os_error() + )) +} diff --git a/crates/trusted-server-release/src/boundary/proof.rs b/crates/trusted-server-release/src/boundary/proof.rs new file mode 100644 index 000000000..b28645e6a --- /dev/null +++ b/crates/trusted-server-release/src/boundary/proof.rs @@ -0,0 +1,520 @@ +//! Construct-only-through-success network denial proof. +//! +//! [`DenialProof`] has private fields, no `Default`, `Clone` or serde +//! implementations, and is built only by [`evaluate`] after, in this process +//! run and for one boundary instance fingerprint (configuration plus a fresh +//! per-instance nonce): +//! +//! 1. the probe ran **unconfined**, exited `0`, reported `connected`, and the +//! control listener accepted exactly one connection; then +//! 2. the same probe ran **confined**, exited with +//! [`DENIED_STATUS`], reported a real `EPERM` +//! or `EACCES`, and the listener accepted nothing. +//! +//! A failed or refused unconfined control (no local socket permission, an +//! enclosing sandbox) yields no proof, so confined denial is never inferred +//! from a probe that could not have connected anyway. + +use std::path::Path; + +use error_stack::Report; +use serde::Serialize; + +use super::probe::{DENIED_STATUS, LoopbackListener, ProbeProgram, ProbeReport}; +use super::{Boundary, ConfinedCommand}; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessLimits, ProcessOutcome, ProcessResult}; + +/// Errnos accepted as an operating-system network denial. +const DENIAL_ERRNOS: [i32; 2] = [libc::EPERM, libc::EACCES]; + +fn proof_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Evidence that this boundary configuration denies network access. +#[derive(Debug)] +pub(crate) struct DenialProof { + fingerprint: [u8; 32], + denied_errno: i32, + observations: ProofObservations, +} + +#[derive(Debug)] +enum ProofObservations { + Measured { + control: Observation, + confined: Observation, + }, + #[cfg(test)] + Fixture, +} + +/// Read-only measured socket diagnostics; never an execution capability. +#[derive(Debug, Serialize)] +pub(crate) struct DenialReceipt { + fingerprint: String, + denied_errno: i32, + control: ProbeReceipt, + probe: ProbeReceipt, +} + +impl DenialReceipt { + /// Returns whether these measured diagnostics name the expected boundary instance. + pub(crate) fn matches_fingerprint(&self, expected: &[u8; 32]) -> bool { + self.fingerprint == hex::encode(expected) + } + + /// Lowercase hex fingerprint of the measured boundary instance. + pub(crate) fn fingerprint(&self) -> &str { + &self.fingerprint + } + + /// Real errno the confined probe reported. + pub(crate) fn denied_errno(&self) -> i32 { + self.denied_errno + } + + /// Exit status and listener accepts of the unconfined control run. + pub(crate) fn control(&self) -> (i32, usize) { + self.control.measured() + } + + /// Exit status and listener accepts of the confined probe run. + pub(crate) fn probe(&self) -> (i32, usize) { + self.probe.measured() + } +} + +#[derive(Debug, Serialize)] +struct ProbeReceipt { + outcome: ExitReceipt, + report: ReportReceipt, + accepted_connections: usize, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ExitReceipt { + Exited { status: i32 }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ReportReceipt { + Connected, + Denied { errno: i32 }, +} + +impl ProbeReceipt { + fn measured(&self) -> (i32, usize) { + let ExitReceipt::Exited { status } = self.outcome; + (status, self.accepted_connections) + } + + fn from_observation(observed: Observation) -> Result> { + let ProcessOutcome::Exited { code } = observed.outcome else { + return Err(proof_error( + "measured socket observation did not exit normally", + )); + }; + let report = match observed.report { + Some(ProbeReport::Connected) => ReportReceipt::Connected, + Some(ProbeReport::Denied { errno }) => ReportReceipt::Denied { errno }, + None => return Err(proof_error("measured socket observation has no report")), + }; + Ok(Self { + outcome: ExitReceipt::Exited { status: code }, + report, + accepted_connections: observed.accepts, + }) + } +} + +impl DenialProof { + /// Exports actual socket observations for the expected boundary instance. + /// + /// # Errors + /// + /// Rejects a mismatched instance, missing measured observations or any + /// control/probe pair that does not establish successful socket denial. + pub(crate) fn receipt_for( + &self, + expected_boundary_fingerprint: &[u8; 32], + ) -> Result> { + if !self.matches(expected_boundary_fingerprint) { + return Err(proof_error( + "measured denial receipt belongs to another boundary instance", + )); + } + let (control, confined) = match &self.observations { + ProofObservations::Measured { control, confined } => (*control, *confined), + #[cfg(test)] + ProofObservations::Fixture => { + return Err(proof_error( + "synthetic test proof has no measured socket observations", + )); + } + }; + if validate_observations(control, confined)? != self.denied_errno { + return Err(proof_error( + "stored denial errno differs from measured observations", + )); + } + Ok(DenialReceipt { + fingerprint: hex::encode(self.fingerprint), + denied_errno: self.denied_errno, + control: ProbeReceipt::from_observation(control)?, + probe: ProbeReceipt::from_observation(confined)?, + }) + } + + /// Returns whether the proof was established for `fingerprint`. + pub(super) fn matches(&self, fingerprint: &[u8; 32]) -> bool { + &self.fingerprint == fingerprint + } + + /// Builds a proof for unit tests that never run a confined command. + #[cfg(test)] + pub(crate) fn for_tests() -> Self { + Self { + fingerprint: [0; 32], + denied_errno: 1, + observations: ProofObservations::Fixture, + } + } + + /// Returns the real errno the confined probe observed. + #[must_use] + pub(crate) fn denied_errno(&self) -> i32 { + self.denied_errno + } +} + +/// One probe run as the controls observed it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Observation { + /// The probe's exit outcome. + outcome: ProcessOutcome, + /// The parsed report, if stdout carried exactly one. + report: Option, + /// Connections the control listener accepted during this run. + accepts: usize, +} + +/// Constructs a proof only from a successful control and a denied probe. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the control did not connect with +/// exactly one observed accept, or the confined probe was not denied with +/// `EPERM`/`EACCES` and zero accepts. +fn evaluate( + fingerprint: [u8; 32], + control: Observation, + confined: Observation, +) -> Result> { + let denied_errno = validate_observations(control, confined)?; + Ok(DenialProof { + fingerprint, + denied_errno, + observations: ProofObservations::Measured { control, confined }, + }) +} + +fn validate_observations( + control: Observation, + confined: Observation, +) -> Result> { + let control_ok = control.outcome == (ProcessOutcome::Exited { code: 0 }) + && control.report == Some(ProbeReport::Connected) + && control.accepts == 1; + if !control_ok { + return Err(proof_error(format!( + "unconfined socket control failed: {control:?}" + ))); + } + let denied_status = ProcessOutcome::Exited { + code: i32::from(DENIED_STATUS), + }; + let denied_errno = match confined.report { + Some(ProbeReport::Denied { errno }) + if confined.outcome == denied_status + && DENIAL_ERRNOS.contains(&errno) + && confined.accepts == 0 => + { + errno + } + _ => { + return Err(proof_error(format!( + "confined probe was not denied: {confined:?}" + ))); + } + }; + Ok(denied_errno) +} + +fn observe( + probe: &ProbeProgram, + listener: &LoopbackListener, + result: ProcessResult, +) -> Result> { + let observation = Observation { + outcome: result.outcome(), + report: probe.parse(result.stdout()), + accepts: listener.drain_accepts()?, + }; + match observation.outcome { + ProcessOutcome::Exited { code } if code == 0 || code == i32::from(DENIED_STATUS) => { + Ok(observation) + } + _ => result.require_success().map(|_| observation), + } +} + +fn unconfined_command( + probe: &ProbeProgram, + listener: &LoopbackListener, + limits: ProcessLimits, +) -> Result> { + let mut command = BoundedCommand::new(probe.program(), Path::new("/"), limits)? + .arguments(probe.arguments(listener.address())); + for (name, value) in probe.environment() { + command = command.environment(name, value)?; + } + Ok(command) +} + +fn confined_command( + boundary: &Boundary, + probe: &ProbeProgram, + listener: &LoopbackListener, + limits: ProcessLimits, +) -> Result> { + let working_directory = boundary + .roots + .writable() + .first() + .ok_or_else(|| proof_error("boundary has no writable root"))?; + let mut command = ConfinedCommand::new(probe.program(), working_directory, limits)? + .arguments(probe.arguments(listener.address())); + for (name, value) in probe.environment() { + command = command.environment(name, value)?; + } + Ok(command) +} + +/// Runs the unconfined control, then the confined probe, and evaluates both. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when either control fails, the probe is +/// outside the boundary's roots or the listener fails, or +/// [`ReleaseError::Process`] when a probe child cannot be run. +pub(super) fn establish( + boundary: &Boundary, + probe: &ProbeProgram, + limits: ProcessLimits, +) -> Result> { + let listener = LoopbackListener::bind()?; + if listener.drain_accepts()? != 0 { + return Err(proof_error( + "control listener had a connection before the control", + )); + } + let control_result = unconfined_command(probe, &listener, limits)?.run()?; + let control = observe(probe, &listener, control_result)?; + let confined_result = + boundary.execute(&confined_command(boundary, probe, &listener, limits)?)?; + let confined = observe(probe, &listener, confined_result)?; + evaluate(boundary.fingerprint, control, confined) +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + use std::time::Duration; + + use serde_json::json; + + use super::*; + + #[test] + fn unexpected_probe_exit_keeps_bounded_startup_diagnostic() { + let probe = ProbeProgram::release_binary().expect("should locate probe executable"); + let listener = LoopbackListener::bind().expect("should bind fixture listener"); + let result = BoundedCommand::new( + Path::new("/bin/sh"), + Path::new("/"), + ProcessLimits { + wall: Duration::from_secs(5), + stdout_max: 1024, + stderr_max: 1024, + }, + ) + .expect("should select fixture command") + .arguments( + [ + "-c", + "printf 'fictional container startup failure' >&2; exit 125", + ] + .map(OsString::from), + ) + .run() + .expect("should supervise fixture child"); + let error = observe(&probe, &listener, result) + .expect_err("should reject failed probe child before protocol evaluation"); + assert!( + format!("{error:?}").contains("fictional container startup failure"), + "should preserve bounded child failure diagnostic" + ); + } + + fn observation(code: i32, report: Option, accepts: usize) -> Observation { + Observation { + outcome: ProcessOutcome::Exited { code }, + report, + accepts, + } + } + + fn denied(errno: i32, accepts: usize) -> Observation { + observation(3, Some(ProbeReport::Denied { errno }), accepts) + } + + #[test] + fn measured_receipt_retains_validated_control_and_probe_observations() { + let fingerprint = [7; 32]; + let control = observation(0, Some(ProbeReport::Connected), 1); + for errno in [libc::EPERM, libc::EACCES] { + let proof = evaluate(fingerprint, control, denied(errno, 0)) + .expect("should evaluate a successful pure observation fixture"); + let receipt = proof + .receipt_for(&fingerprint) + .expect("should export validated observations for the same boundary"); + assert!( + receipt.matches_fingerprint(&fingerprint), + "should bind diagnostics to the measured boundary instance" + ); + assert!( + !receipt.matches_fingerprint(&[8; 32]), + "should reject an unrelated boundary fingerprint" + ); + let value = + serde_json::to_value(&receipt).expect("should serialize measured diagnostics"); + assert_eq!( + value, + json!({ + "fingerprint": hex::encode(fingerprint), "denied_errno": errno, + "control": {"outcome":{"kind":"exited","status":0},"report":{"kind":"connected"},"accepted_connections":1}, + "probe": {"outcome":{"kind":"exited","status":3},"report":{"kind":"denied","errno":errno},"accepted_connections":0} + }), + "should retain exactly the successful fixture observations without claiming an operating-system run" + ); + } + } + + #[test] + fn measured_receipt_rejects_other_instances_and_unmeasured_test_proofs() { + let proof = evaluate( + [7; 32], + observation(0, Some(ProbeReport::Connected), 1), + denied(libc::EPERM, 0), + ) + .expect("should evaluate a successful pure observation fixture"); + assert!( + proof.receipt_for(&[8; 32]).is_err(), + "should reject a different boundary instance" + ); + assert!( + DenialProof::for_tests().receipt_for(&[0; 32]).is_err(), + "should never export measured success from a synthetic test capability" + ); + } + + #[test] + fn measured_receipt_revalidates_stored_observations_and_errno() { + let control = observation(0, Some(ProbeReport::Connected), 1); + let confined = denied(libc::EPERM, 0); + for (changed_control, changed_confined) in [ + (observation(0, Some(ProbeReport::Connected), 0), confined), + (control, denied(libc::EPERM, 1)), + (control, observation(3, None, 0)), + ] { + let mut proof = evaluate([7; 32], control, confined) + .expect("should evaluate a successful pure observation fixture"); + proof.observations = ProofObservations::Measured { + control: changed_control, + confined: changed_confined, + }; + assert!( + proof.receipt_for(&[7; 32]).is_err(), + "should reject corrupted stored observations" + ); + } + let mut proof = evaluate([7; 32], control, confined) + .expect("should evaluate a successful pure observation fixture"); + proof.denied_errno = libc::EACCES; + assert!( + proof.receipt_for(&[7; 32]).is_err(), + "should reject a stored errno that differs from the observed report" + ); + } + + #[test] + fn proof_requires_successful_control_and_denied_probe() { + let fingerprint = [7; 32]; + let control = observation(0, Some(ProbeReport::Connected), 1); + let proof = + evaluate(fingerprint, control, denied(libc::EPERM, 0)).expect("should prove denial"); + assert_eq!( + proof.denied_errno(), + libc::EPERM, + "should keep the real errno" + ); + assert!(proof.matches(&fingerprint), "should bind the instance"); + assert!( + !proof.matches(&[8; 32]), + "should not match another instance" + ); + let eacces = + evaluate(fingerprint, control, denied(libc::EACCES, 0)).expect("should accept EACCES"); + assert_eq!(eacces.denied_errno(), libc::EACCES, "should keep EACCES"); + } + + #[test] + fn proof_rejects_failed_controls() { + let bad_controls = [ + denied(libc::ECONNREFUSED, 0), + observation(0, Some(ProbeReport::Connected), 0), + observation(0, Some(ProbeReport::Connected), 2), + observation(0, None, 1), + observation(1, Some(ProbeReport::Connected), 1), + ]; + for control in bad_controls { + assert!( + evaluate([7; 32], control, denied(libc::EPERM, 0)).is_err(), + "should reject failed control {control:?}" + ); + } + } + + #[test] + fn proof_rejects_undenied_probes() { + let control = observation(0, Some(ProbeReport::Connected), 1); + let bad_confined = [ + observation(0, Some(ProbeReport::Connected), 1), + denied(libc::ECONNREFUSED, 0), + denied(111, 0), + denied(libc::EPERM, 1), + observation(0, Some(ProbeReport::Denied { errno: libc::EPERM }), 0), + observation(3, None, 0), + ]; + for confined in bad_confined { + assert!( + evaluate([7; 32], control, confined).is_err(), + "should reject undenied probe {confined:?}" + ); + } + } +} diff --git a/crates/trusted-server-release/src/boundary/roots.rs b/crates/trusted-server-release/src/boundary/roots.rs new file mode 100644 index 000000000..de214fc96 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/roots.rs @@ -0,0 +1,545 @@ +//! Boundary root validation, alias checks and immutable tree facts. +//! +//! Every root is canonicalized, must be UTF-8 without quote, backslash or +//! control characters (so it can travel as an SBPL `-D` define or a Docker +//! mount field without escaping), and must be disjoint from every other root. +//! +//! Immutable roots are scanned before and after each confined execution. +//! Every regular file must have `nlink == 1`: a pre-existing hard link outside +//! the root writes through path-based write denial, and a write followed by +//! content, mode and mtime restoration is invisible to a hash comparison, so +//! the link itself is rejected before anything runs. Symlinks must be +//! relative, resolve inside their root and not dangle, which keeps their +//! meaning identical in the host and container namespaces. + +use std::collections::BTreeMap; +use std::fs::{self, File, Metadata}; +use std::io::Read as _; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::{Component, Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; + +/// Maximum number of roots per role; SBPL parameter slots are fixed. +pub(crate) const MAX_ROOTS: usize = 8; +/// File read size while hashing immutable files. +const HASH_CHUNK: usize = 64 * 1024; + +fn boundary_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// How a root is exposed inside the boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum RootRole { + /// Read-only source or object store, scanned and rehashed. + #[display("immutable")] + Immutable, + /// Read-only authenticated tool file or directory. + #[display("tool")] + Tool, + /// Private writable installation or output root. + #[display("writable")] + Writable, + /// Read-and-execute-only host system tool directory, such as the Apple + /// developer directory. + /// + /// It is never scanned: its identity is an explicit record of versions + /// and tool hashes held by the caller, not a tree snapshot. + #[display("system")] + System, +} + +/// Validated, canonical and pairwise-disjoint boundary roots. +/// +/// System roots take part in equality, so a boundary configured without the +/// exact system roots never matches a recipe that requires them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct BoundaryRoots { + immutable: Vec, + tools: Vec, + writable: Vec, + system: Vec, +} + +/// Rejects path text that cannot travel unescaped as a profile define. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] for a relative or non-UTF-8 path, the +/// filesystem root, or one containing `"`, `\` or any control character +/// (including newline and NUL). +pub(crate) fn validate_path_text(path: &Path) -> Result<&str, Report> { + let text = path + .to_str() + .ok_or_else(|| boundary_error("boundary path is not UTF-8"))?; + if !path.is_absolute() || text == "/" { + return Err(boundary_error(format!( + "boundary path {text:?} must be absolute and not the filesystem root" + ))); + } + if text + .chars() + .any(|c| c == '"' || c == '\\' || c.is_control()) + { + return Err(boundary_error(format!( + "boundary path {text:?} contains a quote, backslash or control character" + ))); + } + Ok(text) +} + +fn canonical_root(path: &Path, role: RootRole) -> Result> { + validate_path_text(path)?; + let canonical = fs::canonicalize(path) + .map_err(|error| boundary_error(format!("cannot resolve {role} root: {error}")))?; + validate_path_text(&canonical)?; + let metadata = fs::symlink_metadata(&canonical) + .map_err(|error| boundary_error(format!("cannot inspect {role} root: {error}")))?; + let accepted = metadata.is_dir() || (role == RootRole::Tool && metadata.is_file()); + if !accepted { + return Err(boundary_error(format!( + "{role} root {} has an unsupported file type", + canonical.display() + ))); + } + Ok(canonical) +} + +fn canonical_roots( + paths: &[PathBuf], + role: RootRole, +) -> Result, Report> { + if paths.len() > MAX_ROOTS { + return Err(boundary_error(format!( + "at most {MAX_ROOTS} {role} roots are supported" + ))); + } + paths + .iter() + .map(|path| canonical_root(path, role)) + .collect() +} + +/// Returns whether either path contains the other. +#[must_use] +pub(crate) fn overlaps(first: &Path, second: &Path) -> bool { + first.starts_with(second) || second.starts_with(first) +} + +impl BoundaryRoots { + /// Canonicalizes and validates the roots. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for invalid path text, a missing + /// root, a non-directory immutable or writable root, more than + /// [`MAX_ROOTS`] roots of one role, no writable root, or any two roots + /// that are equal or nested. + pub(crate) fn new( + immutable: &[PathBuf], + tools: &[PathBuf], + writable: &[PathBuf], + ) -> Result> { + let roots = Self { + immutable: canonical_roots(immutable, RootRole::Immutable)?, + tools: canonical_roots(tools, RootRole::Tool)?, + writable: canonical_roots(writable, RootRole::Writable)?, + system: Vec::new(), + }; + if roots.writable.is_empty() { + return Err(boundary_error("a boundary needs a private writable root")); + } + roots.check_disjoint()?; + Ok(roots) + } + + /// Adds read-and-execute-only system roots, replacing any set before. + /// + /// System roots are never scanned or written; the macOS profile allows + /// only reading and executing below them and the Linux container + /// boundary refuses them. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for invalid path text, a missing or + /// non-directory root, more than [`MAX_ROOTS`] system roots, or a system + /// root equal to or nested with any other root. + pub(crate) fn with_system(mut self, system: &[PathBuf]) -> Result> { + self.system = canonical_roots(system, RootRole::System)?; + self.check_disjoint()?; + Ok(self) + } + + /// Requires every pair of roots to be neither equal nor nested. + fn check_disjoint(&self) -> Result<(), Report> { + let all: Vec<(RootRole, usize, &Path)> = self.entries().collect(); + for (index, (_, _, first)) in all.iter().enumerate() { + for (_, _, second) in all.iter().skip(index + 1) { + if overlaps(first, second) { + return Err(boundary_error(format!( + "boundary roots {} and {} are not disjoint", + first.display(), + second.display() + ))); + } + } + } + Ok(()) + } + + /// Returns these roots without the last writable root, which must be + /// `excluded`. + /// + /// Only the last writable root may be dropped, so every remaining root + /// keeps its role, per-role index and therefore its execution path. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when `excluded` cannot be resolved, + /// is not the last writable root, or is the only writable root. + pub(crate) fn without_last_writable( + &self, + excluded: &Path, + ) -> Result> { + let excluded = fs::canonicalize(excluded).map_err(|error| { + boundary_error(format!( + "cannot resolve the excluded writable root: {error}" + )) + })?; + let Some((last, remaining)) = self.writable.split_last() else { + return Err(boundary_error("a boundary needs a private writable root")); + }; + if *last != excluded { + return Err(boundary_error( + "only the last writable root may be excluded without shifting root indices", + )); + } + if remaining.is_empty() { + return Err(boundary_error( + "excluding the only writable root leaves no private writable root", + )); + } + Ok(Self { + immutable: self.immutable.clone(), + tools: self.tools.clone(), + writable: remaining.to_vec(), + system: self.system.clone(), + }) + } + + /// Returns every root with its role and per-role index. + pub(crate) fn entries(&self) -> impl Iterator { + fn tag( + role: RootRole, + paths: &[PathBuf], + ) -> impl Iterator { + paths + .iter() + .enumerate() + .map(move |(index, path)| (role, index, path.as_path())) + } + tag(RootRole::Immutable, &self.immutable) + .chain(tag(RootRole::Tool, &self.tools)) + .chain(tag(RootRole::Writable, &self.writable)) + .chain(tag(RootRole::System, &self.system)) + } + + /// Returns the read-and-execute-only system roots. + #[must_use] + pub(crate) fn system(&self) -> &[PathBuf] { + &self.system + } + + /// Returns the immutable roots. + #[must_use] + pub(crate) fn immutable(&self) -> &[PathBuf] { + &self.immutable + } + + /// Returns the tool roots. + #[must_use] + pub(crate) fn tools(&self) -> &[PathBuf] { + &self.tools + } + + /// Returns the writable roots. + #[must_use] + pub(crate) fn writable(&self) -> &[PathBuf] { + &self.writable + } + + /// Returns the root containing `path` and the path relative to it. + #[must_use] + pub(crate) fn locate<'a>(&self, path: &'a Path) -> Option<(RootRole, usize, &'a Path)> { + self.entries().find_map(|(role, index, root)| { + path.strip_prefix(root) + .ok() + .map(|relative| (role, index, relative)) + }) + } +} + +/// The observed identity of one entry under an immutable root. +#[derive(Clone, Debug, PartialEq, Eq)] +enum EntryKind { + Directory, + File { size: u64, sha256: [u8; 32] }, + Symlink { target: PathBuf }, +} + +/// Metadata compared before and after each confined execution. +#[derive(Clone, Debug, PartialEq, Eq)] +struct EntryFacts { + kind: EntryKind, + mode: u32, + nlink: u64, + device: u64, + inode: u64, + mtime_ns: i128, + ctime_ns: i128, +} + +/// A complete snapshot of one immutable root. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TreeFacts { + root: PathBuf, + entries: BTreeMap, +} + +/// What a tree scan records. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ScanPolicy { + /// Hash files, require `nlink == 1` and check aliases. + Immutable, + /// Require `nlink == 1` and check aliases without hashing (writable + /// roots before a run). + SingleLinks, + /// Check aliases and file types only (writable roots after a run). + AliasesOnly, +} + +fn nanos(seconds: i64, nanoseconds: i64) -> i128 { + i128::from(seconds) * 1_000_000_000 + i128::from(nanoseconds) +} + +fn facts(kind: EntryKind, metadata: &Metadata) -> EntryFacts { + EntryFacts { + kind, + mode: metadata.mode(), + nlink: metadata.nlink(), + device: metadata.dev(), + inode: metadata.ino(), + mtime_ns: nanos(metadata.mtime(), metadata.mtime_nsec()), + ctime_ns: nanos(metadata.ctime(), metadata.ctime_nsec()), + } +} + +/// Hashes a regular file opened without following links, rejecting hard +/// links and a file swapped between `lstat` and `open`. +fn hash_file(path: &Path, listed: &Metadata) -> Result<[u8; 32], Report> { + let mut file = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|error| boundary_error(format!("cannot open {}: {error}", path.display())))?; + let opened = file + .metadata() + .map_err(|error| boundary_error(format!("cannot stat {}: {error}", path.display())))?; + if opened.dev() != listed.dev() || opened.ino() != listed.ino() { + return Err(boundary_error(format!( + "{} changed while it was scanned", + path.display() + ))); + } + if opened.nlink() != 1 { + return Err(boundary_error(format!( + "file {} has {} hard links; a link outside the root would bypass write denial", + path.display(), + opened.nlink() + ))); + } + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; HASH_CHUNK]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| boundary_error(format!("cannot read {}: {error}", path.display())))?; + let Some(chunk) = buffer.get(..read).filter(|chunk| !chunk.is_empty()) else { + return Ok(hasher.finalize().into()); + }; + hasher.update(chunk); + } +} + +/// Lexically resolves a relative link target from the link's directory, +/// returning `None` when it climbs above the root. +fn lexical_target(link_relative: &Path, target: &Path) -> Option { + let mut resolved: Vec> = link_relative.parent()?.components().collect(); + for component in target.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + resolved.pop()?; + } + Component::Normal(_) => resolved.push(component), + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(resolved.iter().collect()) +} + +/// Rejects an alias that is absolute, escapes the root or dangles. +fn check_alias(root: &Path, relative: &Path) -> Result> { + let path = root.join(relative); + let target = fs::read_link(&path) + .map_err(|error| boundary_error(format!("cannot read link {}: {error}", path.display())))?; + if target.is_absolute() { + return Err(boundary_error(format!( + "link {} is absolute and resolves differently inside the container namespace", + path.display() + ))); + } + if lexical_target(relative, &target).is_none() { + return Err(boundary_error(format!( + "link {} escapes its root", + path.display() + ))); + } + let resolved = fs::canonicalize(&path) + .map_err(|error| boundary_error(format!("link {} dangles: {error}", path.display())))?; + if !resolved.starts_with(root) { + return Err(boundary_error(format!( + "link {} resolves outside its root", + path.display() + ))); + } + Ok(target) +} + +fn entry_kind( + root: &Path, + relative: &Path, + metadata: &Metadata, + policy: ScanPolicy, +) -> Result> { + let file_type = metadata.file_type(); + if file_type.is_dir() { + return Ok(EntryKind::Directory); + } + if file_type.is_symlink() { + return Ok(EntryKind::Symlink { + target: check_alias(root, relative)?, + }); + } + if !file_type.is_file() { + return Err(boundary_error(format!( + "{} is a socket, FIFO or device; only files, directories and links are allowed", + entry_path(root, relative).display() + ))); + } + let path = entry_path(root, relative); + let sha256 = match policy { + ScanPolicy::Immutable => hash_file(&path, metadata)?, + ScanPolicy::SingleLinks if metadata.nlink() != 1 => { + return Err(boundary_error(format!( + "file {} has {} hard links; a link outside the root would bypass write denial", + path.display(), + metadata.nlink() + ))); + } + ScanPolicy::SingleLinks | ScanPolicy::AliasesOnly => [0; 32], + }; + Ok(EntryKind::File { + size: metadata.len(), + sha256, + }) +} + +/// Returns `root` itself for the empty relative path (a file root) and the +/// joined path otherwise. +fn entry_path(root: &Path, relative: &Path) -> PathBuf { + if relative.as_os_str().is_empty() { + root.to_path_buf() + } else { + root.join(relative) + } +} + +fn child_names(directory: &Path) -> Result, Report> { + let read_error = |error: std::io::Error| { + boundary_error(format!("cannot list {}: {error}", directory.display())) + }; + let mut names = Vec::new(); + for entry in fs::read_dir(directory).map_err(read_error)? { + names.push(PathBuf::from(entry.map_err(read_error)?.file_name())); + } + Ok(names) +} + +impl TreeFacts { + /// Scans `root` (canonical) under `policy`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for an unreadable entry, a + /// non-regular special file, a hard-linked regular file (immutable and + /// single-link policies), or an absolute, escaping or dangling symlink. + pub(crate) fn scan(root: &Path, policy: ScanPolicy) -> Result> { + let mut entries = BTreeMap::new(); + let mut pending = vec![PathBuf::new()]; + while let Some(relative) = pending.pop() { + let path = entry_path(root, &relative); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + boundary_error(format!("cannot inspect {}: {error}", path.display())) + })?; + let kind = entry_kind(root, &relative, &metadata, policy)?; + if kind == EntryKind::Directory { + pending.extend( + child_names(&path)? + .into_iter() + .map(|name| relative.join(name)), + ); + } + entries.insert(relative, facts(kind, &metadata)); + } + Ok(Self { + root: root.to_path_buf(), + entries, + }) + } + + /// Requires `after` to match this snapshot exactly. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] naming the first added, removed or + /// changed entry. + pub(crate) fn ensure_unchanged(&self, after: &Self) -> Result<(), Report> { + if self.root != after.root { + return Err(boundary_error("tree snapshots describe different roots")); + } + let changed = self + .entries + .iter() + .find(|(path, facts)| after.entries.get(*path) != Some(facts)) + .map(|(path, _)| path) + .or_else(|| { + after + .entries + .keys() + .find(|path| !self.entries.contains_key(*path)) + }); + match changed { + None => Ok(()), + Some(path) => Err(boundary_error(format!( + "immutable root {} changed at {:?}", + self.root.display(), + path + ))), + } + } +} diff --git a/crates/trusted-server-release/src/boundary/seccomp.rs b/crates/trusted-server-release/src/boundary/seccomp.rs new file mode 100644 index 000000000..3dc31f7c2 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/seccomp.rs @@ -0,0 +1,123 @@ +//! Effective Docker seccomp profile derived from the source-owned baseline. +//! +//! The baseline is the reviewed `moby/profiles` `seccomp/v0.2.1` default +//! profile retained in `scripts/release/qualification/`. Its SHA-256 is +//! checked before use. The effective profile preserves the baseline's default +//! `SCMP_ACT_ERRNO`/`EPERM` action, native architectures and every other +//! syscall rule except process-memory and descriptor-transfer allowances. +//! It empties every `subArchitectures` list, so non-native ABIs +//! (x86 and x32 on `x86_64`, where sockets go through `socketcall`; 32-bit +//! ARM on `aarch64`) are not admitted by the filter at all and fail with the +//! filter's bad-architecture action. It removes every `socket`, `socketpair` +//! and `socketcall` allowance and adds exactly one rule allowing +//! `socket`/`socketpair` with argument zero equal to `AF_UNIX`, so INET, +//! INET6, netlink and packet sockets fail with `EPERM`. `--network none` alone still permits loopback INET +//! sockets, so this layer is what turns them into `EPERM`. + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; + +/// The reviewed baseline bytes, compiled in from the source tree. +const BASELINE: &[u8] = + include_bytes!("../../../../scripts/release/qualification/seccomp-default.json"); +/// SHA-256 of the reviewed baseline recorded in the qualification README. +pub(crate) const BASELINE_SHA256: &str = + "536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74"; +/// Socket-creating syscalls whose baseline allowances are removed. +const SOCKET_SYSCALLS: [&str; 3] = ["socket", "socketpair", "socketcall"]; +/// An install hook must not inspect or modify its trusted observer's memory. +const PROCESS_ACCESS_SYSCALLS: [&str; 6] = [ + "ptrace", + "process_vm_readv", + "process_vm_writev", + "pidfd_getfd", + "kcmp", + "process_madvise", +]; +/// `AF_UNIX` on every Linux architecture. +const AF_UNIX: u64 = 1; + +fn seccomp_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Returns the hex SHA-256 of `bytes`. +#[must_use] +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Returns the baseline after checking its reviewed digest. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the compiled-in bytes do not match +/// [`BASELINE_SHA256`]. +pub(crate) fn baseline() -> Result<&'static [u8], Report> { + if sha256_hex(BASELINE) != BASELINE_SHA256 { + return Err(seccomp_error( + "seccomp baseline digest does not match review", + )); + } + Ok(BASELINE) +} + +fn strip_socket_names(rule: &mut Value) -> Result> { + let names = rule + .get_mut("names") + .and_then(Value::as_array_mut) + .ok_or_else(|| seccomp_error("seccomp rule has no names array"))?; + names.retain(|name| { + name.as_str().is_none_or(|name| { + !SOCKET_SYSCALLS.contains(&name) && !PROCESS_ACCESS_SYSCALLS.contains(&name) + }) + }); + Ok(!names.is_empty()) +} + +/// Derives the effective profile from `baseline`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the baseline is not JSON, does not +/// default to `SCMP_ACT_ERRNO` with errno `1`, or has a malformed rule. +pub(crate) fn effective(baseline: &[u8]) -> Result, Report> { + let mut profile: Value = serde_json::from_slice(baseline) + .map_err(|error| seccomp_error(format!("seccomp baseline is not JSON: {error}")))?; + let default_denies = profile.get("defaultAction") == Some(&json!("SCMP_ACT_ERRNO")) + && profile.get("defaultErrnoRet") == Some(&json!(1)); + if !default_denies { + return Err(seccomp_error("seccomp baseline does not default to EPERM")); + } + let architectures = profile + .get_mut("archMap") + .and_then(Value::as_array_mut) + .ok_or_else(|| seccomp_error("seccomp baseline has no archMap"))?; + for architecture in architectures { + let entry = architecture + .as_object_mut() + .ok_or_else(|| seccomp_error("seccomp archMap entry is not an object"))?; + entry.insert(String::from("subArchitectures"), json!([])); + } + let rules = profile + .get_mut("syscalls") + .and_then(Value::as_array_mut) + .ok_or_else(|| seccomp_error("seccomp baseline has no syscalls array"))?; + let mut retained = Vec::with_capacity(rules.len() + 1); + for mut rule in rules.drain(..) { + if strip_socket_names(&mut rule)? { + retained.push(rule); + } + } + retained.push(json!({ + "names": ["socket", "socketpair"], + "action": "SCMP_ACT_ALLOW", + "args": [{"index": 0, "value": AF_UNIX, "op": "SCMP_CMP_EQ"}], + })); + *rules = retained; + serde_json::to_vec_pretty(&profile) + .map_err(|error| seccomp_error(format!("cannot serialize seccomp profile: {error}"))) +} diff --git a/crates/trusted-server-release/src/boundary/selector_placement_tests.rs b/crates/trusted-server-release/src/boundary/selector_placement_tests.rs new file mode 100644 index 000000000..e88df1a2f --- /dev/null +++ b/crates/trusted-server-release/src/boundary/selector_placement_tests.rs @@ -0,0 +1,127 @@ +//! Pure tests of tool-selector placement and sandbox wrapping; nothing runs. + +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + +use super::macos::{MacProfile, RunIdentity}; +use super::*; + +struct Layout { + _directory: tempfile::TempDir, + base: PathBuf, + roots: BoundaryRoots, +} + +fn layout() -> Layout { + let directory = tempfile::tempdir().expect("should create layout"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize layout"); + for relative in ["tools/bin", "checkout", "state"] { + fs::create_dir_all(base.join(relative)).expect("should create root"); + } + let roots = BoundaryRoots::new( + &[base.join("checkout")], + &[base.join("tools")], + &[base.join("state")], + ) + .expect("should build roots"); + Layout { + _directory: directory, + base, + roots, + } +} + +fn command(layout: &Layout, rustc: &std::path::Path) -> ConfinedCommand { + ConfinedCommand::new( + &layout.base.join("tools/bin/cargo"), + &layout.base.join("checkout"), + ProcessLimits { + wall: Duration::from_secs(1), + stdout_max: 1024, + stderr_max: 1024, + }, + ) + .expect("should describe the command") + .tool_environment("RUSTC", rustc) + .expect("should accept an absolute RUSTC") +} + +#[cfg(target_os = "macos")] +#[test] +fn rustc_must_be_the_tool_roots_own_bin_rustc() { + let layout = layout(); + let boundary = + Boundary::mac_sandbox_for_tests(layout.roots.clone()).expect("should configure, not run"); + boundary + .check_placement(&command(&layout, &layout.base.join("tools/bin/rustc"))) + .expect("should accept the tool root's rustc"); + for (label, other) in [ + ( + "another file in the tool root", + layout.base.join("tools/bin/other"), + ), + ( + "a nested lookalike", + layout.base.join("tools/bin/rustc/rustc"), + ), + ("a file in the checkout", layout.base.join("checkout/rustc")), + ("a file outside every root", PathBuf::from("/usr/bin/rustc")), + ("the tool root itself", layout.base.join("tools")), + ] { + assert!( + boundary.check_placement(&command(&layout, &other)).is_err(), + "should reject RUSTC naming {label}" + ); + } +} + +#[test] +fn sandbox_wrap_carries_rustc_but_still_rejects_the_nonce_variable() { + let layout = layout(); + let profile = MacProfile::generate(&layout.roots).expect("should generate the profile"); + let run = RunIdentity::new(&profile).expect("should create run markers"); + profile + .wrap( + &command(&layout, &layout.base.join("tools/bin/rustc")), + &run, + ) + .expect("should wrap a command carrying the explicit compiler"); + let forged = command(&layout, &layout.base.join("tools/bin/rustc")) + .environment(super::macos::RUN_NONCE_VARIABLE, "x") + .expect("should hold the variable until wrapping"); + assert!( + profile.wrap(&forged, &run).is_err(), + "should reject a caller variable named like the run nonce" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn mac_host_path_maps_only_lexically_normal_writable_paths() { + let layout = layout(); + let boundary = + Boundary::mac_sandbox_for_tests(layout.roots.clone()).expect("should configure, not run"); + let state = layout.base.join("state"); + assert_eq!( + boundary + .host_path(&state.join("release/ts")) + .expect("should accept a normal writable path"), + state.join("release/ts"), + "should map a writable path to itself" + ); + let text = |relative: &str| PathBuf::from(format!("{}/{relative}", state.display())); + for path in [ + text("release/./ts"), + text("release//ts"), + text("release/ts/"), + text("release/../state/ts"), + layout.base.join("checkout/ts"), + layout.base.join("tools/bin/rustc"), + ] { + assert!( + boundary.host_path(&path).is_err(), + "should reject {path:?} as a host path" + ); + } +} diff --git a/crates/trusted-server-release/src/boundary/sibling_tests.rs b/crates/trusted-server-release/src/boundary/sibling_tests.rs new file mode 100644 index 000000000..2c99e50d1 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/sibling_tests.rs @@ -0,0 +1,149 @@ +//! Pure tests of sibling boundary root derivation; nothing runs. + +use std::fs; +use std::path::PathBuf; + +use super::docker::container_path; +use super::macos::MacProfile; +use super::*; + +struct Layout { + _directory: tempfile::TempDir, + base: PathBuf, +} + +fn layout() -> Layout { + let directory = tempfile::tempdir().expect("should create layout"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize layout"); + for relative in ["checkout", "tools/bin", "cargo-state/home", "target"] { + fs::create_dir_all(base.join(relative)).expect("should create root"); + } + Layout { + _directory: directory, + base, + } +} + +impl Layout { + fn recipe_roots(&self) -> BoundaryRoots { + BoundaryRoots::new( + &[self.base.join("checkout")], + &[self.base.join("tools")], + &[self.base.join("cargo-state"), self.base.join("target")], + ) + .expect("should build recipe roots") + } +} + +#[test] +fn excluding_the_last_writable_root_keeps_every_remaining_execution_path() { + let layout = layout(); + let roots = layout.recipe_roots(); + + let sibling = roots + .without_last_writable(&layout.base.join("target")) + .expect("should exclude the target root"); + + assert_eq!( + sibling.writable(), + [layout.base.join("cargo-state")], + "should keep only the Cargo state root writable" + ); + assert_eq!( + sibling.immutable(), + roots.immutable(), + "should keep the immutable roots" + ); + assert_eq!(sibling.tools(), roots.tools(), "should keep the tool roots"); + for host in [ + layout.base.join("checkout"), + layout.base.join("tools/bin"), + layout.base.join("cargo-state/home"), + ] { + assert_eq!( + container_path(&sibling, &host).expect("should translate in the sibling"), + container_path(&roots, &host).expect("should translate in the build"), + "should keep the execution path of {}", + host.display() + ); + } + assert!( + container_path(&sibling, &layout.base.join("target")).is_err(), + "should not expose the excluded target root" + ); +} + +#[test] +fn excluding_a_writable_root_that_would_shift_indices_is_rejected() { + let layout = layout(); + + let error = layout + .recipe_roots() + .without_last_writable(&layout.base.join("cargo-state")) + .expect_err("should reject excluding the first of two writable roots"); + + assert!( + format!("{error:?}").contains("only the last writable root"), + "should explain the index-preservation rule" + ); +} + +#[test] +fn excluding_the_only_writable_root_or_a_foreign_path_is_rejected() { + let layout = layout(); + let single = BoundaryRoots::new( + &[layout.base.join("checkout")], + &[layout.base.join("tools")], + &[layout.base.join("target")], + ) + .expect("should build single-writable roots"); + + assert!( + single + .without_last_writable(&layout.base.join("target")) + .is_err(), + "should keep a private writable root" + ); + assert!( + layout + .recipe_roots() + .without_last_writable(&layout.base.join("checkout")) + .is_err(), + "should reject excluding a root that is not writable" + ); + assert!( + layout + .recipe_roots() + .without_last_writable(&layout.base.join("missing")) + .is_err(), + "should reject an unresolvable exclusion" + ); +} + +#[test] +fn a_macos_boundary_derives_no_sibling() { + let layout = layout(); + let roots = layout.recipe_roots(); + let boundary = Boundary { + platform: Platform::MacSandbox( + MacProfile::generate(&roots).expect("should generate the profile"), + ), + roots, + fingerprint: [1; 32], + derived_from: None, + }; + + let error = boundary + .sibling_without_writable(&layout.base.join("target")) + .expect_err("should reject a macOS sibling"); + + assert!( + format!("{error:?}").contains("only a Linux container boundary"), + "should name the Linux-only rule" + ); + assert_eq!( + boundary.derived_from(), + None, + "should record no parent for a directly configured boundary" + ); +} diff --git a/crates/trusted-server-release/src/boundary/survivors.rs b/crates/trusted-server-release/src/boundary/survivors.rs new file mode 100644 index 000000000..c012346cf --- /dev/null +++ b/crates/trusted-server-release/src/boundary/survivors.rs @@ -0,0 +1,326 @@ +//! Fail-closed detection of confined descendants that outlived a macOS run. +//! +//! macOS has no subreaper, and `posix_spawn` with `POSIX_SPAWN_SETPGROUP` or +//! `POSIX_SPAWN_SETSID` leaves the supervised process group even though the +//! profile denies the `setsid`/`setpgid` system calls. After every run, each +//! other process is checked two ways, and every match is sent `SIGKILL` and +//! the run rejected: +//! +//! - Sandbox instance: the process is sandboxed, may read the run's allow +//! marker and may not read its deny marker, according to `sandbox_check`. +//! A confined process cannot leave or re-apply its sandbox (nested +//! `sandbox_apply` fails with `EPERM`), so scrubbing its environment or +//! changing its process group does not hide it. +//! - Environment: its exec-time arguments and environment (`KERN_PROCARGS2`, +//! readable only for same-user processes) hold `NAME=`. macOS +//! hides this area for Apple platform binaries such as `/bin/sleep`, so +//! this signal is secondary to the sandbox-instance check. +//! +//! Only processes that started after the run began, less a one-second clock +//! margin, are eligible, so already-running applications are never +//! signalled even if a check misbehaves. +//! +//! `sandbox_check` is exported by libSystem's `libsystem_sandbox` but is not +//! declared in the public SDK header. A self-check against this unsandboxed +//! orchestrator must give the expected answers before any scan is trusted; +//! the orchestrator must therefore not itself run inside a sandbox. + +use std::ffi::{CStr, CString}; +use std::os::unix::ffi::OsStrExt as _; +use std::path::Path; +use std::time::{Duration, Instant}; + +use error_stack::Report; + +use crate::error::ReleaseError; + +/// Bound for killing survivors and waiting for them to disappear. +const REAP_GRACE: Duration = Duration::from_secs(2); +/// Polling interval while waiting for killed survivors to vanish. +const POLL_INTERVAL: Duration = Duration::from_millis(10); +/// Extra slots when listing pids, for processes started between calls. +const PID_SLACK: usize = 256; +/// `SANDBOX_FILTER_NONE` in the private `sandbox_filter_type` enumeration. +const SANDBOX_FILTER_NONE: libc::c_int = 0; +/// `SANDBOX_FILTER_PATH` in the same enumeration; observed with +/// `sandbox-exec` profiles on macOS 26.6.2. +const SANDBOX_FILTER_PATH: libc::c_int = 1; +/// The checked file operation. +const FILE_READ_DATA: &CStr = c"file-read-data"; + +unsafe extern "C" { + /// Returns 0 when `operation` is allowed for `pid`, 1 when denied and -1 + /// on error; with no operation, 1 means `pid` is sandboxed. + fn sandbox_check( + pid: libc::pid_t, + operation: *const libc::c_char, + filter: libc::c_int, + ... + ) -> libc::c_int; + /// Flag suppressing sandbox violation reports for a check. + static SANDBOX_CHECK_NO_REPORT: libc::c_int; +} + +/// What identifies one run's processes. +pub(crate) struct RunSignature<'a> { + /// Environment variable name carrying the run nonce. + pub(crate) variable: &'a str, + /// The run nonce. + pub(crate) nonce: &'a str, + /// Marker file the run's sandbox instance may read. + pub(crate) allow: &'a Path, + /// Marker file the run's sandbox instance may not read. + pub(crate) deny: &'a Path, + /// Earliest eligible process start, in microseconds since the epoch. + /// Processes that started before the run, such as already-open + /// applications, are never matched or signalled. + pub(crate) started_after_micros: u64, +} + +/// Prepared queries for one signature. +struct Matcher { + started_after_micros: u64, + entry: Vec, + allow: CString, + deny: CString, +} + +fn survivor_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +fn no_report() -> libc::c_int { + // SAFETY: the exported constant is an initialized, immutable `int`. + unsafe { SANDBOX_CHECK_NO_REPORT } +} + +/// Returns whether `pid` is sandboxed (1), not (0), or the check failed (-1). +fn sandboxed(pid: libc::pid_t) -> libc::c_int { + // SAFETY: a null operation with the none filter takes no variadic value. + unsafe { sandbox_check(pid, core::ptr::null(), SANDBOX_FILTER_NONE | no_report()) } +} + +/// Returns 0 when `pid` may read `path`, 1 when denied, -1 on error. +fn read_check(pid: libc::pid_t, path: &CStr) -> libc::c_int { + // SAFETY: the path filter takes exactly one NUL-terminated string, and + // both strings outlive the call. + unsafe { + sandbox_check( + pid, + FILE_READ_DATA.as_ptr(), + SANDBOX_FILTER_PATH | no_report(), + path.as_ptr(), + ) + } +} + +impl Matcher { + fn new(signature: &RunSignature<'_>) -> Result> { + let path = |path: &Path| { + CString::new(path.as_os_str().as_bytes()) + .map_err(|_| survivor_error("run marker path holds a NUL byte")) + }; + Ok(Self { + started_after_micros: signature.started_after_micros, + entry: format!("{}={}", signature.variable, signature.nonce).into_bytes(), + allow: path(signature.allow)?, + deny: path(signature.deny)?, + }) + } + + /// Requires the expected answers for the unsandboxed orchestrator. + fn self_check(&self, own: libc::pid_t) -> Result<(), Report> { + let answers = ( + sandboxed(own), + read_check(own, &self.allow), + read_check(own, &self.deny), + ); + if answers == (0, 0, 0) { + Ok(()) + } else { + Err(survivor_error(format!( + "sandbox state query self-check failed with {answers:?}; no process was signalled and the orchestrator must run unsandboxed" + ))) + } + } + + /// Returns whether `pid` belongs to the run's sandbox instance. + fn in_instance(&self, pid: libc::pid_t) -> bool { + sandboxed(pid) == 1 && read_check(pid, &self.allow) == 0 && read_check(pid, &self.deny) == 1 + } +} + +/// Returns `pid`'s start time in microseconds since the epoch, when readable. +fn start_micros(pid: libc::pid_t) -> Option { + // SAFETY: `proc_bsdinfo` is plain old data; all-zero bytes are valid. + let mut info: libc::proc_bsdinfo = unsafe { core::mem::zeroed() }; + let size = libc::c_int::try_from(size_of::()).ok()?; + // SAFETY: `info` is a writable buffer of exactly `size` bytes. + let filled = + unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, (&raw mut info).cast(), size) }; + (filled == size).then(|| { + info.pbi_start_tvsec + .saturating_mul(1_000_000) + .saturating_add(info.pbi_start_tvusec) + }) +} + +/// Lists every process ID on the system. +fn all_pids() -> Result, Report> { + // SAFETY: a null buffer of size zero asks only for the current count. + let count = unsafe { libc::proc_listallpids(core::ptr::null_mut(), 0) }; + let count = + usize::try_from(count).map_err(|_| survivor_error("cannot count processes"))? + PID_SLACK; + let mut pids: Vec = vec![0; count]; + let bytes = libc::c_int::try_from(count * size_of::()) + .map_err(|_| survivor_error("process list is too large"))?; + // SAFETY: `pids` has room for exactly `bytes` bytes of pid entries. + let listed = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), bytes) }; + let listed = usize::try_from(listed).map_err(|_| survivor_error("cannot list processes"))?; + pids.truncate(listed.min(count)); + Ok(pids) +} + +/// Returns the kernel's maximum argument-area size (`KERN_ARGMAX`). +fn argument_area_size() -> Result> { + let mut maximum: libc::c_int = 0; + let mut size = size_of::(); + let mut mib = [libc::CTL_KERN, libc::KERN_ARGMAX]; + // SAFETY: `maximum` and `size` describe a valid c_int output buffer. + let rc = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + 2, + (&raw mut maximum).cast(), + &raw mut size, + core::ptr::null_mut(), + 0, + ) + }; + usize::try_from(maximum) + .ok() + .filter(|maximum| rc == 0 && *maximum > 0) + .ok_or_else(|| survivor_error("cannot read KERN_ARGMAX")) +} + +/// Reads `pid`'s exec-time argument and environment area into `buffer`, +/// returning the filled prefix when readable (same-user processes only). +fn process_arguments(pid: libc::pid_t, buffer: &mut [u8]) -> Option<&[u8]> { + let mut size = buffer.len(); + let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid]; + // SAFETY: `buffer` and `size` describe a valid output buffer. + let rc = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + 3, + buffer.as_mut_ptr().cast(), + &raw mut size, + core::ptr::null_mut(), + 0, + ) + }; + if rc == 0 { buffer.get(..size) } else { None } +} + +/// Returns whether `area` holds `entry` as a whole NUL-delimited string. +fn holds_entry(area: &[u8], entry: &[u8]) -> bool { + area.split(|byte| *byte == 0).any(|item| item == entry) +} + +/// Returns `pid`'s start time when it is another eligible process of the +/// run's instance or carries its nonce. +fn identify( + matcher: &Matcher, + pid: libc::pid_t, + own: libc::pid_t, + buffer: &mut [u8], +) -> Option { + if pid <= 1 || pid == own { + return None; + } + let started = start_micros(pid).filter(|started| *started >= matcher.started_after_micros)?; + let carries = matcher.in_instance(pid) + || process_arguments(pid, buffer).is_some_and(|area| holds_entry(area, &matcher.entry)); + carries.then_some(started) +} + +/// Returns every other matching process with its start time. +fn carriers( + matcher: &Matcher, + own: libc::pid_t, + buffer: &mut [u8], +) -> Result, Report> { + matcher.self_check(own)?; + Ok(all_pids()? + .into_iter() + .filter_map(|pid| identify(matcher, pid, own, buffer).map(|started| (pid, started))) + .collect()) +} + +fn own_pid() -> libc::pid_t { + // SAFETY: `getpid` has no preconditions. + unsafe { libc::getpid() } +} + +/// Lists, without signalling them, every other process matching `signature`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the markers are not C strings, +/// the sandbox state query fails its self-check or processes cannot be listed. +#[cfg(test)] +pub(crate) fn matching( + signature: &RunSignature<'_>, +) -> Result, Report> { + let mut buffer = vec![0_u8; argument_area_size()?]; + let found = carriers(&Matcher::new(signature)?, own_pid(), &mut buffer)?; + Ok(found.into_iter().map(|(pid, _)| pid).collect()) +} + +/// Kills every surviving process of the run and rejects the run if any +/// existed. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] naming the survivors when any were +/// found (after killing them), when they did not vanish within the grace, +/// or when the sandbox state query fails its self-check. +pub(crate) fn reap(signature: &RunSignature<'_>) -> Result<(), Report> { + let matcher = Matcher::new(signature)?; + let own = own_pid(); + let mut buffer = vec![0_u8; argument_area_size()?]; + let deadline = Instant::now() + REAP_GRACE; + let mut killed = Vec::new(); + loop { + let found = carriers(&matcher, own, &mut buffer)?; + if found.is_empty() { + break; + } + for (pid, started) in found { + // Re-identify immediately before signalling so that a reused pid + // with a different start time is never killed. + if identify(&matcher, pid, own, &mut buffer) == Some(started) { + // SAFETY: `kill` has no memory-safety preconditions; + // `identify` only returns pids above 1 other than our own. + unsafe { + libc::kill(pid, libc::SIGKILL); + } + killed.push(pid); + } + } + if Instant::now() >= deadline { + return Err(survivor_error(format!( + "confined descendants {killed:?} still exist after SIGKILL" + ))); + } + std::thread::sleep(POLL_INTERVAL); + } + if killed.is_empty() { + return Ok(()); + } + killed.sort_unstable(); + killed.dedup(); + Err(survivor_error(format!( + "confined descendants {killed:?} escaped the process group and were killed" + ))) +} diff --git a/crates/trusted-server-release/src/boundary/tests.rs b/crates/trusted-server-release/src/boundary/tests.rs new file mode 100644 index 000000000..2913b320f --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests.rs @@ -0,0 +1,236 @@ +//! Boundary tests. +//! +//! Fixtures are host-independent: confined children re-execute this test +//! binary in a hidden, nonce-gated helper mode (see [`helper_entry`] and +//! [`modes`]) built from std and libc only — no shells, interpreters or +//! coreutils. The helper speaks the same probe protocol as +//! `boundary-probe connect`, sharing +//! [`connect_probe`](super::probe::connect_probe), after a sentinel that +//! skips the libtest preamble. +//! +//! Tool roots require `nlink == 1`, and Cargo hard-links its test binaries, +//! so every boundary fixture runs a private staged copy of this binary. + +mod container; +mod modes; +mod unit; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command as StdCommand; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use super::*; + +/// Environment selector `:`; absent in normal test runs. +const HELPER_SELECTOR: &str = "TS_RELEASE_BOUNDARY_HELPER"; +/// Environment variable carrying the nonce the selector must match. +const HELPER_NONCE: &str = "TS_RELEASE_BOUNDARY_NONCE"; +/// Exact libtest name of [`helper_entry`]. +const HELPER_TEST: &str = "boundary::tests::helper_entry"; +/// Marks the helper's report after the libtest preamble. +const SENTINEL: &[u8] = b"\x1eTS-BOUNDARY-HELPER\x1e"; +/// libtest arguments selecting only [`helper_entry`]; mode arguments follow. +const HELPER_ARGUMENTS: [&str; 4] = ["--exact", HELPER_TEST, "--nocapture", "--test-threads=1"]; +/// Exit status of a helper whose setup failed. +const HELPER_SETUP_FAILED: i32 = 90; +/// Generous per-run bound for every fixture child. +const SLACK: Duration = Duration::from_secs(60); + +/// Returns limits generous enough for any fixture child. +fn limits() -> ProcessLimits { + ProcessLimits { + wall: SLACK, + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + } +} + +/// Returns this run's helper nonce. +fn nonce() -> &'static str { + static NONCE: OnceLock = OnceLock::new(); + NONCE.get_or_init(|| { + hex::encode(super::random_bytes::<16>().expect("should read nonce entropy")) + }) +} + +/// Returns the canonical (Cargo hard-linked) test binary path. +fn test_binary() -> PathBuf { + std::env::current_exe() + .and_then(std::fs::canonicalize) + .expect("should locate the test binary") +} + +/// Returns the helper environment selecting `mode`. +fn helper_environment(mode: &str) -> Vec<(OsString, OsString)> { + vec![ + ( + OsString::from(HELPER_SELECTOR), + OsString::from(format!("{mode}:{}", nonce())), + ), + (OsString::from(HELPER_NONCE), OsString::from(nonce())), + ] +} + +/// Returns the full helper argument vector for `mode_arguments`. +fn helper_arguments(mode_arguments: &[&str]) -> Vec { + HELPER_ARGUMENTS + .iter() + .chain(mode_arguments) + .map(OsString::from) + .collect() +} + +/// Returns the test-binary probe at `program`. +fn helper_probe(program: &Path) -> ProbeProgram { + ProbeProgram::test_helper( + program.to_path_buf(), + helper_arguments(&[]), + helper_environment("connect"), + SENTINEL, + ) +} + +/// Stages a private single-link copy of the test binary into `tools` and +/// returns its probe. +fn staged_probe(tools: &Path) -> ProbeProgram { + helper_probe(&test_binary()) + .staged(tools) + .expect("should stage the helper binary") +} + +/// Builds a confined helper command running `mode` from `working_directory`. +fn helper_command( + program: &Path, + working_directory: &Path, + mode: &str, + mode_arguments: &[&str], +) -> ConfinedCommand { + let mut command = ConfinedCommand::new(program, working_directory, limits()) + .expect("should describe helper command") + .arguments(helper_arguments(mode_arguments)); + for (name, value) in helper_environment(mode) { + command = command + .environment(name, value) + .expect("should accept helper environment"); + } + command +} + +/// Returns the helper's report text after the sentinel. +fn helper_report(stdout: &[u8]) -> String { + let start = stdout + .windows(SENTINEL.len()) + .rposition(|window| window == SENTINEL) + .expect("should find helper sentinel in stdout"); + String::from_utf8_lossy(&stdout[start + SENTINEL.len()..]) + .trim_end() + .to_owned() +} + +/// Spawns a same-user sibling that lives until killed or its parent pipe closes. +fn spawn_sibling() -> std::process::Child { + let mut command = StdCommand::new(test_binary()); + command + .args(helper_arguments(&[])) + .env_clear() + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()); + for (name, value) in helper_environment("wait-for-eof") { + command.env(name, value); + } + command.spawn().expect("should spawn sibling") +} + +/// Returns whether `pid` still exists (zombies count until reaped). +fn process_exists(pid: libc::pid_t) -> bool { + // SAFETY: signal 0 only checks existence; callers pass a pid > 1. + unsafe { libc::kill(pid, 0) == 0 } +} + +/// Waits up to five seconds for `pid` to disappear. +fn wait_gone(pid: libc::pid_t) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if !process_exists(pid) { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + false +} + +/// Reads a pid recorded by a helper. +fn read_pid(path: &Path) -> libc::pid_t { + let pid: libc::pid_t = std::fs::read_to_string(path) + .expect("should read recorded pid") + .trim() + .parse() + .expect("should parse recorded pid"); + assert!(pid > 1, "should record a real pid"); + pid +} + +/// Returns the helper mode when the selector, nonce and argv all match a +/// fixture-launched helper run. +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()) +} + +/// 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 rest: Vec = arguments + .iter() + .skip_while(|argument| argument.as_str() != "--test-threads=1") + .skip(1) + .cloned() + .collect(); + let code = mode.map_or(HELPER_SETUP_FAILED, |mode| modes::run(&mode, &rest)); + // SAFETY: `_exit` ends the helper without returning to the harness, so no + // harness summary is appended to the captured output. + unsafe { libc::_exit(code) } +} + +#[test] +fn helper_requires_exact_filter_and_matching_nonce() { + let exact: Vec = ["binary", "--exact", HELPER_TEST] + .iter() + .map(|part| (*part).to_owned()) + .collect(); + assert_eq!( + authorized_mode("connect:abc", Some("abc"), &exact), + Some(String::from("connect")), + "should authorize a fixture-launched helper" + ); + assert_eq!(authorized_mode("connect:abc", Some("xyz"), &exact), None); + assert_eq!(authorized_mode("connect:abc", None, &exact), None); + assert_eq!(authorized_mode("connect:", Some(""), &exact), None); + assert_eq!( + authorized_mode("connect:abc", Some("abc"), &exact[..2]), + None + ); +} diff --git a/crates/trusted-server-release/src/boundary/tests/container.rs b/crates/trusted-server-release/src/boundary/tests/container.rs new file mode 100644 index 000000000..9a35fc826 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/container.rs @@ -0,0 +1,727 @@ +//! Platform-independent seccomp, Docker argv, daemon-socket and host-input +//! tests on real files. + +use std::ffi::CString; +use std::fs::{self, File}; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _, symlink}; +use std::os::unix::net::UnixListener; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use serde_json::Value; +use tempfile::TempDir; + +use super::super::daemon::check_daemon_socket; +use super::super::daemon::{ContainerId, DockerClient}; +use super::super::docker::{ + DockerSettings, RunPlan, cleanup_target, complete_created, container_path, create_arguments, + mounts, start_arguments, validate_image_id, +}; +use super::super::host_inputs::HostInputs; +use super::super::seccomp; +use super::unit::{canonical_tempdir, sample_roots}; +use super::*; + +#[test] +fn effective_seccomp_denies_inet_sockets_and_preserves_baseline() { + let baseline = seccomp::baseline().expect("should match reviewed digest"); + let original: Value = serde_json::from_slice(baseline).expect("should parse baseline"); + let effective: Value = + serde_json::from_slice(&seccomp::effective(baseline).expect("should derive profile")) + .expect("should parse effective profile"); + for key in ["defaultAction", "defaultErrnoRet"] { + assert_eq!( + original.get(key), + effective.get(key), + "should preserve {key}" + ); + } + let rules = effective["syscalls"] + .as_array() + .expect("should have syscalls"); + let socket_rules: Vec<&Value> = rules + .iter() + .filter(|rule| { + rule["names"].as_array().is_some_and(|names| { + names.iter().any(|name| { + matches!(name.as_str(), Some("socket" | "socketpair" | "socketcall")) + }) + }) + }) + .collect(); + assert_eq!(socket_rules.len(), 1, "should keep exactly one socket rule"); + assert_eq!( + socket_rules[0]["args"], + serde_json::json!([{"index": 0, "value": 1, "op": "SCMP_CMP_EQ"}]), + "should allow only AF_UNIX" + ); + let original_rules = original["syscalls"] + .as_array() + .expect("should have syscalls"); + assert_eq!( + rules.len(), + original_rules.len() - 5 + 1, + "should drop three socket-only and two process-memory rules, then add the Unix socket rule" + ); + let mut allowing = original.clone(); + allowing["defaultAction"] = Value::from("SCMP_ACT_ALLOW"); + let bytes = serde_json::to_vec(&allowing).expect("should serialize"); + assert!( + seccomp::effective(&bytes).is_err(), + "should reject a permissive baseline" + ); +} + +fn docker_settings(client: &Path) -> DockerSettings { + DockerSettings { + client: client.to_path_buf(), + cpus: 1, + memory_bytes: 256 * 1024 * 1024, + pids_limit: 128, + tmpfs_bytes: 16 * 1024 * 1024, + user: (1001, 1001), + stop_timeout_seconds: 5, + host_limits: limits(), + } +} + +#[test] +fn docker_create_arguments_are_exact() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let mounts = mounts(&roots).expect("should build mounts"); + let image = format!("sha256:{}", "b".repeat(64)); + let environment = [( + OsString::from("HOME"), + OsString::from("/boundary/writable/0"), + )]; + let arguments = [OsString::from("--flag")]; + let mut plan = RunPlan { + config: Path::new("/host/config"), + seccomp: Path::new("/host/seccomp.json"), + name: "ts-release-boundary-fixture", + image: &image, + mounts: &mounts, + environment: &environment, + working_directory: Path::new("/boundary/writable/0"), + program: Path::new("/boundary/tool/0"), + arguments: &arguments, + interactive: false, + umask: None, + }; + let actual: Vec = + create_arguments(&docker_settings(Path::new("/usr/bin/docker")), &plan) + .iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect(); + let root = base.to_str().expect("should be UTF-8"); + assert_eq!( + actual, + expected_create_arguments(root, &image), + "should build the exact fixed argv" + ); + assert!( + !actual.iter().any(|part| part == "--pid" + || part.starts_with("--pid=") + || part == "-t" + || part == "--tty"), + "should never pass --pid or a TTY" + ); + plan.interactive = true; + let interactive = create_arguments(&docker_settings(Path::new("/usr/bin/docker")), &plan); + assert!( + interactive.contains(&OsString::from("--interactive")), + "should keep created stdin open for later attached input" + ); + assert!( + !interactive.contains(&OsString::from("--rm")), + "should retain the acknowledged container until explicit cleanup" + ); +} + +/// The exact argv expected for the fixture plan rooted at `root`. +/// Fixed argv prefix expected before the mounts. +const EXPECTED_PREFIX: [&str; 32] = [ + "--config", + "/host/config", + "--host", + "unix:///var/run/docker.sock", + "create", + "--name", + "ts-release-boundary-fixture", + "--init", + "--pull", + "never", + "--network", + "none", + "--ipc", + "none", + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges=true", + "--security-opt", + "seccomp=/host/seccomp.json", + "--pids-limit", + "128", + "--cpus", + "1", + "--memory", + "268435456b", + "--user", + "1001:1001", + "--tmpfs", + "/tmp:ro,noexec,nosuid,nodev,size=16777216", + "--mount", +]; + +fn expected_create_arguments(root: &str, image: &str) -> Vec { + EXPECTED_PREFIX + .iter() + .map(|part| (*part).to_owned()) + .chain([ + format!("type=bind,source={root}/immutable,target=/boundary/immutable/0,readonly,bind-propagation=rprivate,bind-recursive=readonly"), + String::from("--mount"), + format!("type=bind,source={root}/tool,target=/boundary/tool/0,readonly,bind-propagation=rprivate,bind-recursive=readonly"), + String::from("--mount"), + format!("type=bind,source={root}/writable,target=/boundary/writable/0"), + ]) + .chain( + [ + "--env", + "HOME=/boundary/writable/0", + "--workdir", + "/boundary/writable/0", + image, + "/boundary/tool/0", + "--flag", + ] + .iter() + .map(|part| (*part).to_owned()), + ) + .collect() +} + +#[test] +fn docker_paths_translate_and_reject_mount_metacharacters() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + assert_eq!( + container_path(&roots, &base.join("writable/out/x")).expect("should translate"), + PathBuf::from("/boundary/writable/0/out/x"), + "should map into the writable mount" + ); + assert!( + container_path(&roots, Path::new("/elsewhere")).is_err(), + "should reject paths outside every root" + ); + let comma = base.join("with,comma"); + fs::create_dir(&comma).expect("should create directory"); + let roots = BoundaryRoots::new(&[], &[], &[comma]).expect("should accept roots"); + assert!( + mounts(&roots).is_err(), + "should reject ',' in mount sources" + ); +} + +#[test] +fn docker_settings_fit_host_capacity() { + let client = Path::new("/usr/bin/docker"); + assert!( + docker_settings(client).validate().is_ok(), + "should accept modest limits" + ); + let parallelism = std::thread::available_parallelism().expect("should read parallelism"); + let mut settings = docker_settings(client); + settings.cpus = u32::try_from(parallelism.get()).expect("should fit") + 1; + assert!( + settings.validate().is_err(), + "should reject CPUs above host capacity" + ); + for mutate in [ + (|settings: &mut DockerSettings| settings.cpus = 0) as fn(&mut DockerSettings), + |settings| settings.memory_bytes = u64::MAX, + |settings| settings.user = (0, 0), + |settings| settings.stop_timeout_seconds = 0, + |settings| settings.stop_timeout_seconds = 3600, + ] { + let mut settings = docker_settings(client); + mutate(&mut settings); + assert!(settings.validate().is_err(), "should reject {settings:?}"); + } +} + +#[test] +fn image_must_be_pinned_by_content_id() { + assert!(validate_image_id(&format!("sha256:{}", "c".repeat(64))).is_ok()); + for rejected in [ + "ubuntu:24.04", + "sha256:abc", + &format!("sha256:{}", "C".repeat(64)), + ] { + assert!( + validate_image_id(rejected).is_err(), + "should reject {rejected}" + ); + } +} + +fn host_inputs_fixture() -> (TempDir, PathBuf, HostInputs) { + let (directory, base) = canonical_tempdir(); + let client = base.join("docker-client"); + fs::write(&client, b"fictional client").expect("should write client"); + let state = base.join("state"); + fs::create_dir(&state).expect("should create state parent"); + let inputs = HostInputs::create(&state, &client, &[]).expect("should create inputs"); + (directory, client, inputs) +} + +fn make_writable(path: &Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("should chmod"); +} + +#[test] +fn host_inputs_rehash_detects_tampering_between_commands() { + let (_directory, _client, inputs) = host_inputs_fixture(); + inputs.verify().expect("should verify fresh inputs"); + let effective = inputs.effective_profile().to_path_buf(); + make_writable(&effective, 0o644); + fs::write(&effective, b"{}").expect("should tamper profile"); + assert!( + inputs.verify().is_err(), + "should detect a changed effective profile" + ); + + let (_directory, _client, inputs) = host_inputs_fixture(); + let baseline = inputs.directory().join("seccomp-baseline.json"); + make_writable(&baseline, 0o644); + fs::write(&baseline, b"{}").expect("should tamper baseline"); + assert!( + inputs.verify().is_err(), + "should detect a changed baseline profile" + ); + + let (_directory, _client, inputs) = host_inputs_fixture(); + make_writable(inputs.config(), 0o700); + fs::write(inputs.config().join("config.json"), b"{}").expect("should add config"); + assert!(inputs.verify().is_err(), "should detect a non-empty config"); + + let (_directory, client, inputs) = host_inputs_fixture(); + fs::write(&client, b"substituted client").expect("should replace client"); + assert!(inputs.verify().is_err(), "should detect a changed client"); + + let (_directory, _client, inputs) = host_inputs_fixture(); + fs::write(inputs.directory().join("extra"), b"x").expect("should add entry"); + assert!(inputs.verify().is_err(), "should detect an added entry"); +} + +#[test] +fn host_inputs_must_be_disjoint_from_mounted_roots() { + let (_directory, base) = canonical_tempdir(); + let client = base.join("docker-client"); + fs::write(&client, b"fictional client").expect("should write client"); + let mounted = base.join("mounted"); + fs::create_dir_all(mounted.join("inner")).expect("should create mount"); + for parent in [base.clone(), mounted.clone(), mounted.join("inner")] { + assert!( + HostInputs::create(&parent, &client, &[&mounted]).is_err(), + "should reject state parent {parent:?} overlapping a mount" + ); + } + let alias = base.join("client-alias"); + symlink(&client, &alias).expect("should create client alias"); + let state = base.join("state"); + fs::create_dir(&state).expect("should create state"); + assert!( + HostInputs::create(&state, &alias, &[&mounted]).is_err(), + "should reject an aliased client path" + ); +} + +#[test] +fn docker_client_must_be_outside_every_mounted_root() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let state = base.join("state"); + fs::create_dir(&state).expect("should create state directory"); + let mounted: Vec<&Path> = roots.entries().map(|(_, _, path)| path).collect(); + for root in &mounted { + let client = if root.is_dir() { + root.join("docker-client") + } else { + root.to_path_buf() + }; + fs::write(&client, b"fictional client").expect("should create fixture client"); + assert!( + HostInputs::create(&state, &client, &mounted).is_err(), + "should reject a host executable exposed through a mounted root" + ); + } +} + +#[test] +fn host_input_fifo_is_rejected_without_waiting_for_a_writer() { + let (_directory, base) = canonical_tempdir(); + let client = base.join("docker-client"); + let state = base.join("state"); + fs::create_dir(&state).expect("should create state directory"); + let name = CString::new(client.as_os_str().as_bytes()).expect("should encode FIFO path"); + // SAFETY: The path is NUL-terminated and remains valid during mkfifo. + assert_eq!( + unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, + 0, + "should create FIFO" + ); + let (sender, receiver) = mpsc::channel(); + let reader_path = client.clone(); + let reader = thread::spawn(move || { + let rejected = HostInputs::create(&state, &reader_path, &[]).is_err(); + let _ = sender.send(rejected); + }); + let result = receiver.recv_timeout(Duration::from_millis(200)); + // Release a regressed blocking open before joining, so failure cannot hang CI. + let _keeper = File::options() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(&client) + .expect("should open nonblocking FIFO cleanup handle"); + reader.join().expect("should reap input reader"); + assert_eq!(result, Ok(true), "should reject FIFO without a writer"); +} + +#[test] +fn cleanup_never_retries_through_changed_host_inputs() { + let (_directory, base) = canonical_tempdir(); + let client_path = base.join("docker-client"); + fs::write(&client_path, b"fictional client").expect("should create fixture client"); + let client = + DockerClient::new(&client_path, &base, &[], limits()).expect("should create pinned client"); + fs::write(&client_path, b"substituted client").expect("should replace fixture client"); + let mut failures = Vec::new(); + assert!( + client.cleanup_host(&["version"], &mut failures).is_none(), + "should reject cleanup after integrity failure" + ); + assert_eq!( + failures.len(), + 1, + "should stop at integrity failure before endpoint inspection or execution" + ); +} + +#[cfg(not(target_os = "linux"))] +#[test] +fn container_boundary_is_refused_off_linux() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let settings = docker_settings(Path::new("/usr/bin/docker")); + let image = super::super::docker::ImageSelection::Test { + id: format!("sha256:{}", "a".repeat(64)), + }; + assert!( + Boundary::linux_container(roots, settings, image, &base).is_err(), + "should refuse the container boundary off Linux" + ); +} + +#[test] +fn daemon_socket_rejects_user_owned_aliased_and_shared_paths() { + let (_directory, base) = canonical_tempdir(); + let socket = base.join("d.sock"); + let _listener = UnixListener::bind(&socket).expect("should bind fixture socket"); + let error = check_daemon_socket(&socket).expect_err("should reject a user-owned path"); + assert!( + format!("{error:?}").contains("root-owned"), + "should explain ownership" + ); + let alias = base.join("alias.sock"); + symlink(&socket, &alias).expect("should alias socket"); + assert!( + check_daemon_socket(&alias).is_err(), + "should reject an alias" + ); + assert!( + check_daemon_socket(&base.join("missing.sock")).is_err(), + "should reject a missing socket" + ); + if cfg!(target_os = "macos") { + // Root-owned socket under the root-owned but group-writable + // /private/var/run: rejected for its shared-writable ancestor. + assert!( + check_daemon_socket(Path::new("/var/run/mDNSResponder")).is_err(), + "should reject a socket under a group-writable directory" + ); + } +} + +#[test] +fn effective_seccomp_admits_only_native_architectures() { + let baseline = seccomp::baseline().expect("should match reviewed digest"); + let original: Value = serde_json::from_slice(baseline).expect("should parse baseline"); + let effective: Value = + serde_json::from_slice(&seccomp::effective(baseline).expect("should derive profile")) + .expect("should parse effective profile"); + let native = |profile: &Value| -> Vec { + profile["archMap"] + .as_array() + .expect("should have archMap") + .iter() + .map(|entry| entry["architecture"].clone()) + .collect() + }; + assert_eq!( + native(&original), + native(&effective), + "should keep native ABIs" + ); + for entry in effective["archMap"] + .as_array() + .expect("should have archMap") + { + assert_eq!( + entry["subArchitectures"], + serde_json::json!([]), + "should admit no non-native ABI (x86/x32 socketcall)" + ); + } +} + +#[test] +fn effective_seccomp_has_no_process_memory_or_descriptor_transfer_allowance() { + let effective: Value = serde_json::from_slice( + &seccomp::effective(seccomp::baseline().expect("should authenticate baseline")) + .expect("should derive effective profile"), + ) + .expect("should parse profile"); + for rule in effective["syscalls"].as_array().expect("should list rules") { + for name in rule["names"].as_array().expect("should list syscall names") { + assert!( + ![ + "ptrace", + "process_vm_readv", + "process_vm_writev", + "pidfd_getfd", + "kcmp", + "process_madvise" + ] + .contains(&name.as_str().expect("should have syscall name")), + "should remove process-memory and descriptor access from every allowance" + ); + } + } +} + +/// Returns the argv tail after the image for a fixture plan. +fn argv_after_image(umask: Option, program: &str, arguments: &[&str]) -> Vec { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let mounts = mounts(&roots).expect("should build mounts"); + let image = format!("sha256:{}", "b".repeat(64)); + let arguments: Vec = arguments.iter().map(OsString::from).collect(); + let plan = RunPlan { + config: Path::new("/host/config"), + seccomp: Path::new("/host/seccomp.json"), + name: "ts-release-boundary-fixture", + image: &image, + mounts: &mounts, + environment: &[], + working_directory: Path::new("/boundary/writable/0"), + program: Path::new(program), + arguments: &arguments, + interactive: false, + umask, + }; + let argv: Vec = create_arguments(&docker_settings(Path::new("/usr/bin/docker")), &plan) + .iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect(); + let position = argv + .iter() + .position(|part| *part == image) + .expect("should place the image in argv"); + argv[position + 1..].to_vec() +} + +#[test] +fn umask_validation_accepts_only_permission_bits() { + for valid in [0, 0o022, 0o027, 0o777] { + assert_eq!( + Umask::new(valid) + .expect("should accept a permission mask") + .mask(), + valid, + "should round-trip {valid:#o}" + ); + } + for invalid in [0o1000, 0o4777, 0x1_0000, u32::MAX] { + assert!( + Umask::new(invalid).is_err(), + "should reject {invalid:#o} outside 0..=0o777" + ); + } +} + +#[test] +fn umask_displays_as_three_octal_digits() { + let rendered: Vec = [0, 0o7, 0o022, 0o027, 0o777] + .into_iter() + .map(|mask| Umask::new(mask).expect("should accept mask").to_string()) + .collect(); + assert_eq!( + rendered, + ["000", "007", "022", "027", "777"], + "should render zero-padded three-digit octal" + ); +} + +#[test] +fn docker_argv_without_umask_runs_the_program_directly() { + assert_eq!( + argv_after_image(None, "/boundary/tool/0", &["--flag"]), + ["/boundary/tool/0", "--flag"], + "should not involve a shell without a umask" + ); +} + +#[test] +fn docker_argv_with_umask_uses_the_fixed_shell_idiom() { + let umask = Umask::new(0o027).expect("should accept mask"); + assert_eq!( + argv_after_image(Some(umask), "/boundary/tool/0", &["--flag", "value"]), + [ + "/bin/sh", + "-c", + "umask 027 && exec \"$@\"", + "sh", + "/boundary/tool/0", + "--flag", + "value" + ], + "should wrap the program in the fixed umask script with positional arguments" + ); +} + +#[test] +fn docker_argv_keeps_shell_metacharacters_positional() { + let umask = Umask::new(0o022).expect("should accept mask"); + let hostile = [ + "$(touch /boundary/writable/0/pwned)", + "`id`", + "a; rm -rf /", + "'quoted' \"double\" && || | > <", + "-c", + "$@", + "", + ]; + let tail = argv_after_image(Some(umask), "/boundary/tool/0;$(id)", &hostile); + assert_eq!( + tail[..4], + ["/bin/sh", "-c", "umask 022 && exec \"$@\"", "sh"], + "should keep the script fixed whatever the program and arguments are" + ); + assert_eq!( + tail[4], "/boundary/tool/0;$(id)", + "should pass the program as one positional word" + ); + assert_eq!( + tail[5..], + hostile, + "should pass every hostile argument verbatim as its own word" + ); +} + +#[test] +fn start_attaches_only_to_the_confirmed_full_container_id() { + let id = ContainerId::parse("a".repeat(64).as_bytes()).expect("should accept full ID"); + for interactive in [false, true] { + let actual = start_arguments(Path::new("/host/config"), &id, interactive); + let mut expected: Vec = [ + "--config", + "/host/config", + "--host", + "unix:///var/run/docker.sock", + "start", + "--attach", + ] + .into_iter() + .map(OsString::from) + .collect(); + if interactive { + expected.push(OsString::from("--interactive")); + } + expected.push(OsString::from(id.as_str())); + assert_eq!( + actual, expected, + "should attach by exact ID and preserve requested stdin" + ); + } +} + +#[test] +fn unresolved_create_never_starts_even_when_name_cleanup_observes_absence() { + let mut cleanup_attempted = false; + let result = complete_created::<()>( + Err(boundary_error("unresolved creation")), + |_| panic!("should never start an unacknowledged container"), + |id| { + assert!(id.is_none(), "should clean up only the unresolved name"); + cleanup_attempted = true; + Ok(()) + }, + ); + assert!( + cleanup_attempted, + "should attempt cleanup after failed creation" + ); + assert!( + result.is_err(), + "should retain unresolved creation despite current absence" + ); +} + +#[test] +fn acknowledged_start_failure_cleans_up_the_same_id() { + let id = ContainerId::parse("b".repeat(64).as_bytes()).expect("should accept full ID"); + let expected = id.as_str().to_owned(); + let mut cleanup_attempted = false; + let result = complete_created::<()>( + Ok(id), + |id| { + assert_eq!(id.as_str(), expected, "should start the acknowledged ID"); + Err(boundary_error("attached start failed")) + }, + |id| { + assert_eq!( + id.expect("should clean a confirmed ID").as_str(), + expected, + "should remove the exact acknowledged ID" + ); + cleanup_attempted = true; + Ok(()) + }, + ); + assert!( + cleanup_attempted, + "should attempt cleanup after start failure" + ); + assert!(result.is_err(), "should retain attached start failure"); +} + +#[test] +fn confirmed_cleanup_filters_by_id_and_unresolved_cleanup_filters_by_name() { + let id = ContainerId::parse("c".repeat(64).as_bytes()).expect("should accept full ID"); + assert_eq!( + cleanup_target("fixture-name", Some(&id)), + (id.as_str(), format!("id={}", id.as_str())), + "should never mistake an ID for a container name in absence confirmation" + ); + assert_eq!( + cleanup_target("fixture-name", None), + ("fixture-name", "name=^/fixture-name$".to_owned()), + "should restrict unresolved best-effort cleanup to the owned name" + ); +} diff --git a/crates/trusted-server-release/src/boundary/tests/linux.rs b/crates/trusted-server-release/src/boundary/tests/linux.rs new file mode 100644 index 000000000..d846a42e9 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/linux.rs @@ -0,0 +1,377 @@ +//! Real Docker container boundary tests (Linux only). +//! +//! On Linux these REQUIRE a reachable Docker daemon at the fixed socket and +//! fail otherwise; they never skip. The image comes from +//! `TS_RELEASE_TEST_IMAGE` (default `ubuntu:24.04`, whose glibc matches the +//! `ubuntu-24.04` runner that builds this test binary). A missing image is +//! pulled once under a bounded deadline; a failed pull fails the test. +//! +//! What the confined probe shows: a staged copy of the test binary, +//! bind-mounted read-only, attempts a TCP connect to `127.0.0.1:` +//! inside the container. Under `--network none` that is the container's own +//! loopback, so without the effective seccomp profile the result would be +//! `ECONNREFUSED`; the proof requires `EPERM`, which only the seccomp socket +//! denial produces. It shows nothing about routes to other hosts. + +use std::fs; +use std::os::unix::fs::symlink; + +use tempfile::{TempDir, tempdir}; + +use super::super::daemon::DockerClient; +use super::super::docker::{DockerSettings, ImageSelection, container_path}; +use super::super::probe::LoopbackListener; +use super::*; +use crate::process::{ProcessOutcome, ProcessResult}; + +/// The fixed Docker client on GitHub-hosted Ubuntu runners. +const DOCKER_CLIENT: &str = "/usr/bin/docker"; +const TEST_IMAGE_VARIABLE: &str = "TS_RELEASE_TEST_IMAGE"; +const DEFAULT_TEST_IMAGE: &str = "ubuntu:24.04"; +/// `EROFS`: a write into a read-only bind mount. +const READ_ONLY_FILESYSTEM: &str = "denied errno=30"; +/// `ESRCH`: the host pid does not exist in the container's PID namespace. +const NO_SUCH_PROCESS: &str = "denied errno=3"; + +fn host_limits() -> ProcessLimits { + ProcessLimits { + wall: Duration::from_secs(300), + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + } +} + +fn settings() -> DockerSettings { + // SAFETY: `getuid`/`getgid` have no preconditions. + let user = unsafe { (libc::getuid(), libc::getgid()) }; + DockerSettings { + client: PathBuf::from(DOCKER_CLIENT), + cpus: 1, + memory_bytes: 256 * 1024 * 1024, + pids_limit: 128, + tmpfs_bytes: 16 * 1024 * 1024, + user, + stop_timeout_seconds: 5, + host_limits: host_limits(), + } +} + +fn succeeded(result: &ProcessResult) -> bool { + result.outcome() == (ProcessOutcome::Exited { code: 0 }) +} + +/// Resolves the test image to its local content ID, pulling it if absent. +fn test_image(client: &DockerClient) -> ImageSelection { + let reference = + std::env::var(TEST_IMAGE_VARIABLE).unwrap_or_else(|_| String::from(DEFAULT_TEST_IMAGE)); + let inspect = [ + "image", + "inspect", + "--format", + "{{.Id}}", + reference.as_str(), + ]; + let mut result = client + .host(&inspect) + .expect("should run docker image inspect"); + if !succeeded(&result) { + let pull = client + .host(&["pull", reference.as_str()]) + .expect("should run docker pull"); + assert!( + succeeded(&pull), + "should pull test image {reference}: {pull:?}" + ); + result = client + .host(&inspect) + .expect("should rerun docker image inspect"); + } + assert!(succeeded(&result), "should inspect test image {reference}"); + let id = String::from_utf8_lossy(result.stdout()).trim().to_owned(); + ImageSelection::Test { id } +} + +struct Fixture { + _roots: TempDir, + _state: TempDir, + immutable: PathBuf, + tools: PathBuf, + writable: PathBuf, + probe: ProbeProgram, + boundary: Boundary, +} + +/// Creates fixture roots under `base` and returns them. +fn create_roots(base: &Path) -> (PathBuf, PathBuf, PathBuf) { + let immutable = base.join("immutable"); + let tools = base.join("tools"); + let writable = base.join("writable"); + fs::create_dir_all(immutable.join("src")).expect("should create immutable root"); + fs::create_dir(&tools).expect("should create tool root"); + fs::create_dir(&writable).expect("should create writable root"); + fs::write(immutable.join("src/a.txt"), b"original").expect("should write source"); + (immutable, tools, writable) +} + +/// Requires the daemon and resolves the test image. +fn reachable_image(state: &Path) -> ImageSelection { + let client = DockerClient::new(Path::new(DOCKER_CLIENT), state, &[], host_limits()) + .expect("should pin docker client inputs"); + let version = client + .host(&["version", "--format", "{{.Server.Version}}"]) + .expect("should run docker version"); + assert!( + succeeded(&version), + "should reach the Docker daemon at the fixed socket: {version:?}" + ); + test_image(&client) +} + +impl Fixture { + fn new() -> Self { + let roots_directory = tempdir().expect("should create roots directory"); + let state = tempdir().expect("should create state directory"); + let base = fs::canonicalize(roots_directory.path()).expect("should canonicalize"); + let (immutable, tools, writable) = create_roots(&base); + let probe = staged_probe(&tools); + let roots = BoundaryRoots::new( + std::slice::from_ref(&immutable), + std::slice::from_ref(&tools), + std::slice::from_ref(&writable), + ) + .expect("should accept roots"); + let image = reachable_image(state.path()); + let boundary = Boundary::linux_container(roots, settings(), image, state.path()) + .expect("should configure container boundary"); + Self { + _roots: roots_directory, + _state: state, + immutable, + tools, + writable, + probe, + boundary, + } + } + + fn prove(&self) -> DenialProof { + self.boundary + .establish_denial(&self.probe, limits()) + .expect("should prove denial with a real control and denied container probe") + } + + fn container(&self, host: &Path) -> String { + container_path(&self.boundary.roots, host) + .expect("should translate path") + .to_string_lossy() + .into_owned() + } + + fn command(&self, mode: &str, arguments: &[&str]) -> ConfinedCommand { + helper_command(self.probe.program(), &self.writable, mode, arguments) + } + + fn run(&self, proof: &DenialProof, mode: &str, arguments: &[&str]) -> String { + let result = self + .boundary + .run_confined(&self.command(mode, arguments), proof) + .expect("should run confined helper"); + helper_report(result.stdout()) + } +} + +#[test] +fn container_denies_process_memory_access() { + assert_eq!( + super::modes::process_memory_read(), + 0, + "should permit the real self-memory operation outside confinement" + ); + let fixture = Fixture::new(); + let proof = fixture.prove(); + assert_eq!( + fixture.run(&proof, "process-memory-read", &[]), + "denied errno=1", + "should deny the same process-memory syscall with real seccomp EPERM" + ); +} + +#[test] +fn container_denial_proof_and_confined_execution() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + assert_eq!( + proof.denied_errno(), + libc::EPERM, + "should observe seccomp EPERM" + ); + + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + let marker = fixture.container(&fixture.writable); + let report = fixture.run(&proof, "nest", &["2", &address, &marker]); + assert_eq!( + report, "denied errno=1", + "should deny the grandchild's connect" + ); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 0, + "should see no accept" + ); + for depth in 0..=2 { + assert!( + fixture.writable.join(format!("nest-{depth}")).exists(), + "should record nesting level {depth}" + ); + } + + let source = fixture.container(&fixture.immutable.join("src/a.txt")); + assert_eq!( + fixture.run(&proof, "write", &[&source]), + READ_ONLY_FILESYSTEM + ); + assert_eq!( + fs::read(fixture.immutable.join("src/a.txt")).expect("should read"), + b"original", + "should keep the immutable source" + ); + let private = fixture.container(&fixture.writable.join("out.txt")); + assert_eq!(fixture.run(&proof, "write", &[&private]), "written"); + assert_eq!( + fs::read(fixture.writable.join("out.txt")).expect("should read"), + b"tampered", + "should allow private writes" + ); +} + +#[test] +fn container_denies_every_non_unix_socket_family() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + for domain in ["inet", "inet6", "netlink", "packet"] { + assert_eq!( + fixture.run(&proof, "socket", &[domain]), + "denied errno=1", + "should deny {domain} sockets with seccomp EPERM" + ); + } + assert_eq!(fixture.run(&proof, "socket", &["unix"]), "created"); + let socket = fixture.container(&fixture.writable.join("x.sock")); + assert_eq!( + fixture.run(&proof, "unix-exchange", &[&socket]), + "exchanged" + ); +} + +#[test] +fn container_cannot_signal_a_host_sibling() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let mut sibling = spawn_sibling(); + let pid = sibling.id().to_string(); + let kill = libc::SIGKILL.to_string(); + let report = fixture.run(&proof, "signal", &[&pid, &kill]); + let alive = sibling.try_wait().expect("should poll sibling").is_none(); + sibling.kill().expect("should stop sibling"); + sibling.wait().expect("should reap sibling"); + assert_eq!(report, NO_SUCH_PROCESS, "should not see host pids"); + assert!(alive, "should leave the host sibling running"); +} + +#[test] +fn container_is_removed_after_a_timeout() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let mut command = fixture.command("sleep", &["30000"]); + command.limits.wall = Duration::from_secs(3); + let result = fixture + .boundary + .run_confined(&command, &proof) + .expect("should confirm the container removed after the timeout"); + assert_eq!( + result.outcome(), + ProcessOutcome::TimedOut, + "should time out" + ); +} + +#[test] +fn container_rejects_unsafe_aliases_and_hard_links_in_mounts() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let cases: [(&Path, &str, &str); 4] = [ + (&fixture.writable, "absolute", "/etc"), + (&fixture.immutable, "escaping", "../../outside"), + (&fixture.immutable, "dangling", "src/missing"), + (&fixture.tools, "dangling", "missing-tool"), + ]; + for (root, name, target) in cases { + let link = root.join(name); + symlink(target, &link).expect("should create alias"); + assert!( + fixture + .boundary + .run_confined(&fixture.command("sleep", &["0"]), &proof) + .is_err(), + "should reject {name} alias {target} in {root:?}" + ); + fs::remove_file(&link).expect("should remove alias"); + } + let alias = fixture.writable.join("prelink"); + fs::hard_link(fixture.immutable.join("src/a.txt"), &alias).expect("should hard link"); + let error = fixture + .boundary + .run_confined(&fixture.command("sleep", &["0"]), &proof) + .expect_err("should reject a pre-existing hard link"); + assert!( + format!("{error:?}").contains("hard links"), + "should name the hard link" + ); +} + +#[test] +fn docker_client_rehash_rejects_tampering_between_commands() { + let state = tempdir().expect("should create state directory"); + let client = DockerClient::new(Path::new(DOCKER_CLIENT), state.path(), &[], host_limits()) + .expect("should pin inputs"); + let first = client + .host(&["version", "--format", "{{.Client.Version}}"]) + .expect("should run first host command"); + assert!(succeeded(&first), "should run the pinned client"); + let profile = client.inputs().effective_profile().to_path_buf(); + fs::set_permissions( + &profile, + std::os::unix::fs::PermissionsExt::from_mode(0o644), + ) + .expect("should chmod profile"); + fs::write(&profile, b"{}").expect("should tamper profile"); + assert!( + client.host(&["version"]).is_err(), + "should refuse the next host command after tampering" + ); +} + +#[test] +fn container_command_reports_the_requested_umask() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let report = |umask: Option| { + let mut command = fixture.command("umask", &[]); + if let Some(mask) = umask { + command = command.umask(Umask::new(mask).expect("should accept mask")); + } + let result = fixture + .boundary + .run_confined(&command, &proof) + .expect("should run confined helper"); + helper_report(result.stdout()) + }; + let default = report(None); + assert!( + default.len() == 3 && default.bytes().all(|digit| (b'0'..=b'7').contains(&digit)), + "should report the runtime default as three octal digits: {default}" + ); + assert_eq!(report(Some(0o027)), "027", "should apply umask 027"); + assert_eq!(report(Some(0o077)), "077", "should apply umask 077"); +} diff --git a/crates/trusted-server-release/src/boundary/tests/macos.rs b/crates/trusted-server-release/src/boundary/tests/macos.rs new file mode 100644 index 000000000..34abeedde --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/macos.rs @@ -0,0 +1,751 @@ +//! Real `sandbox-exec` boundary tests (macOS only). +//! +//! These must run outside an enclosing application sandbox: the unconfined +//! control needs local socket permission, and without it no proof is +//! constructed and the tests fail instead of passing vacuously. + +use std::fs; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; +use std::time::SystemTime; + +use tempfile::{TempDir, tempdir}; + +use super::super::macos::{MacProfile, RunIdentity}; +use super::super::probe::LoopbackListener; +use super::super::survivors::{RunSignature, matching}; +use super::*; +use crate::process::ProcessOutcome; + +/// A sandbox fixture with one immutable root, a tool root holding a staged +/// copy of the test binary and one private writable root. +struct Fixture { + _directory: TempDir, + immutable: PathBuf, + writable: PathBuf, + probe: ProbeProgram, + boundary: Boundary, +} + +impl Fixture { + fn new() -> Self { + let directory = tempdir().expect("should create fixture directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize fixture"); + let immutable = base.join("immutable"); + let writable = base.join("writable"); + let tools = base.join("tools"); + fs::create_dir_all(immutable.join("src")).expect("should create immutable root"); + fs::create_dir(&writable).expect("should create writable root"); + fs::create_dir(&tools).expect("should create tool root"); + fs::write(immutable.join("src/a.txt"), b"original").expect("should write source"); + let probe = staged_probe(&tools); + let roots = BoundaryRoots::new( + std::slice::from_ref(&immutable), + &[tools], + std::slice::from_ref(&writable), + ) + .expect("should accept fixture roots"); + let boundary = Boundary::mac_sandbox_for_tests(roots).expect("should configure sandbox"); + Self { + _directory: directory, + immutable, + writable, + probe, + boundary, + } + } + + fn prove(&self) -> DenialProof { + self.boundary + .establish_denial(&self.probe, limits()) + .expect("should prove denial with a real control and denied probe") + } + + fn command(&self, mode: &str, arguments: &[&str]) -> ConfinedCommand { + helper_command(self.probe.program(), &self.writable, mode, arguments) + } + + fn run(&self, proof: &DenialProof, mode: &str, arguments: &[&str]) -> String { + let result = self + .boundary + .run_confined(&self.command(mode, arguments), proof) + .expect("should run confined helper"); + helper_report(result.stdout()) + } +} + +fn path_text(path: &Path) -> &str { + path.to_str().expect("should be UTF-8") +} + +#[test] +fn selected_cargo_starts_without_reading_ambient_openssl_configuration() { + let directory = tempdir().expect("should create startup fixture"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize startup fixture"); + let immutable = base.join("source"); + let writable = base.join("private"); + fs::create_dir(&immutable).expect("should create source"); + fs::create_dir(&writable).expect("should create private state"); + let cargo = fs::canonicalize(env!("CARGO")).expect("should locate selected Cargo"); + let roots = BoundaryRoots::new( + &[immutable], + std::slice::from_ref(&cargo), + std::slice::from_ref(&writable), + ) + .expect("should bind startup roots"); + let profile = MacProfile::generate(&roots).expect("should generate actual profile"); + let run = RunIdentity::new(&profile).expect("should create private run markers"); + // These direct version/read commands create no descendants. Use the actual + // profile without the global survivor scan exercised by separate tests. + let execute = |program: &Path, arguments: &[&str]| { + let command = ConfinedCommand::new(program, &writable, limits()) + .expect("should describe harmless command") + .arguments(arguments.iter().map(OsString::from)) + .environment("OPENSSL_CONF", "/dev/null") + .expect("should normalize optional OpenSSL configuration") + .environment("LC_ALL", "C") + .expect("should normalize diagnostic locale") + .environment("HOME", &writable) + .expect("should use private home") + .environment("CARGO_HOME", &writable) + .expect("should use private Cargo home"); + profile + .wrap(&command, &run) + .expect("should wrap command") + .run() + .expect("should finish bounded command") + }; + let version = execute(&cargo, &["--version"]) + .require_success() + .expect("should initialize selected Cargo without ambient configuration"); + assert!( + version.starts_with(b"cargo "), + "should run actual selected Cargo" + ); + for alias in ["/private/etc/ssl/openssl.cnf", "/etc/ssl/openssl.cnf"] { + let denied = execute(Path::new("/bin/cat"), &[alias]); + assert_ne!( + denied.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should deny configuration contents" + ); + assert!( + denied.stdout().is_empty(), + "should expose no configuration bytes" + ); + assert!( + String::from_utf8_lossy(denied.stderr()).contains("No such file or directory"), + "should report optional configuration as absent" + ); + } + let unrelated = execute(Path::new("/bin/cat"), &["/private/etc/hosts"]); + assert_ne!( + unrelated.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should retain unrelated read denial" + ); + assert!( + unrelated.stdout().is_empty(), + "should expose no unrelated host input" + ); + assert!( + String::from_utf8_lossy(unrelated.stderr()).contains("Operation not permitted"), + "should retain ordinary denial outside the exact aliases" + ); +} + +#[test] +fn confined_probe_is_denied_with_eperm_and_no_accept() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + assert_eq!( + proof.denied_errno(), + libc::EPERM, + "should observe real EPERM" + ); + + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + let report = fixture.run(&proof, "connect", &[&address]); + assert_eq!(report, "denied errno=1", "should deny confined connect"); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 0, + "should observe no accept from the confined probe" + ); +} + +#[test] +fn grandchild_inherits_denial() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + let report = fixture.run( + &proof, + "nest", + &["2", &address, path_text(&fixture.writable)], + ); + assert_eq!( + report, "denied errno=1", + "should deny the grandchild's connect" + ); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 0, + "should see no accept" + ); + let pids: Vec = (0..=2) + .map(|depth| { + fs::read_to_string(fixture.writable.join(format!("nest-{depth}"))) + .expect("should record every nesting level") + }) + .collect(); + assert!( + pids[0] != pids[1] && pids[1] != pids[2] && pids[0] != pids[2], + "should run each level as a distinct process: {pids:?}" + ); +} + +#[test] +fn private_unix_exchange_succeeds_while_inet_stays_denied() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let socket = fixture.writable.join("x.sock"); + assert_eq!( + fixture.run(&proof, "unix-exchange", &[path_text(&socket)]), + "exchanged" + ); + let outside = fixture.immutable.join("x.sock"); + assert_eq!( + fixture.run(&proof, "unix-exchange", &[path_text(&outside)]), + "denied errno=1", + "should deny Unix sockets outside the writable roots" + ); + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + assert_eq!( + fixture.run(&proof, "connect", &[&address]), + "denied errno=1" + ); +} + +#[test] +fn writes_are_denied_in_read_only_roots_and_allowed_in_private_roots() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let denied = fixture.immutable.join("src/new.txt"); + let existing = fixture.immutable.join("src/a.txt"); + for target in [&denied, &existing, &fixture.probe.program().to_path_buf()] { + assert_eq!( + fixture.run(&proof, "write", &[path_text(target)]), + "denied errno=1", + "should deny writing {target:?}" + ); + } + assert!( + !denied.exists(), + "should not create files in the immutable root" + ); + assert_eq!( + fs::read(&existing).expect("should read"), + b"original", + "should keep source" + ); + + let private = fixture.writable.join("out.txt"); + assert_eq!( + fixture.run(&proof, "write", &[path_text(&private)]), + "written" + ); + assert_eq!( + fs::read(&private).expect("should read"), + b"tampered", + "should allow private writes" + ); +} + +#[test] +fn signals_are_limited_to_the_same_sandbox() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + assert_eq!( + fixture.run(&proof, "signal-own-child", &[]), + "signalled", + "should signal its own child" + ); + + let mut sibling = spawn_sibling(); + let sibling_pid = sibling.id().to_string(); + let kill = libc::SIGKILL.to_string(); + assert_eq!( + fixture.run(&proof, "signal", &[&sibling_pid, &kill]), + "denied errno=1", + "should not signal an outside same-user sibling" + ); + let alive = sibling.try_wait().expect("should poll sibling").is_none(); + sibling.kill().expect("should stop sibling"); + sibling.wait().expect("should reap sibling"); + assert!(alive, "should leave the sibling running"); + + let orchestrator = std::process::id().to_string(); + assert_eq!( + fixture.run(&proof, "signal", &[&orchestrator, "0"]), + "denied errno=1", + "should not signal the orchestrator" + ); +} + +#[test] +fn setsid_and_setpgid_are_denied() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + for mode in ["setsid", "setpgid"] { + assert_eq!( + fixture.run(&proof, mode, &[]), + "denied errno=1", + "should deny {mode} inside the sandbox" + ); + } +} + +#[test] +fn same_group_orphan_is_killed_with_the_run() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let pid_file = fixture.writable.join("orphan.pid"); + assert_eq!( + fixture.run(&proof, "orphan", &[path_text(&pid_file)]), + "spawned" + ); + let pid = read_pid(&pid_file); + assert!(wait_gone(pid), "should kill the same-group orphan {pid}"); +} + +#[test] +fn group_escape_keeping_the_run_nonce_is_killed_and_rejected() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let pid_file = fixture.writable.join("escape.pid"); + let error = fixture + .boundary + .run_confined( + &fixture.command("escape", &[path_text(&pid_file), "keep"]), + &proof, + ) + .expect_err("should reject a run whose descendant escaped the group"); + assert!( + format!("{error:?}").contains("escaped"), + "should name the escape: {error:?}" + ); + let pid = read_pid(&pid_file); + assert!(wait_gone(pid), "should kill the escaped descendant {pid}"); +} + +#[test] +fn group_escape_with_scrubbed_environment_is_killed_and_rejected() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let pid_file = fixture.writable.join("scrubbed.pid"); + let outcome = fixture.boundary.run_confined( + &fixture.command("escape", &[path_text(&pid_file), "scrub"]), + &proof, + ); + let pid = read_pid(&pid_file); + let survived = process_exists(pid); + if survived { + // SAFETY: `pid` is the fixture's own escaped helper (> 1). + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + let error = outcome.expect_err("should reject a run whose scrubbed descendant escaped"); + assert!( + format!("{error:?}").contains("escaped"), + "should name the escape: {error:?}" + ); + assert!( + !survived, + "should kill the scrubbed escaped descendant {pid} before returning" + ); +} + +/// Collect-only: lists matches without signalling anything. +#[test] +fn fresh_run_signature_matches_no_existing_process() { + let directory = tempdir().expect("should create marker directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize markers"); + let allow = base.join("allow"); + let deny = base.join("deny"); + fs::write(&allow, b"").expect("should write allow marker"); + fs::write(&deny, b"").expect("should write deny marker"); + let matches = matching(&RunSignature { + variable: "TS_RELEASE_BOUNDARY_RUN", + nonce: "fictional-unused-nonce", + allow: &allow, + deny: &deny, + started_after_micros: 0, + }) + .expect("should query every process without signalling it"); + assert!( + matches.is_empty(), + "should match no unrelated sandboxed or unsandboxed process: {matches:?}" + ); +} + +/// Collect-only: the single carrier is this test's own child. +#[test] +fn processes_started_before_the_run_are_never_matched() { + let directory = tempdir().expect("should create marker directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize markers"); + let allow = base.join("allow"); + let deny = base.join("deny"); + fs::write(&allow, b"").expect("should write allow marker"); + fs::write(&deny, b"").expect("should write deny marker"); + let nonce = "fictional-start-guard-nonce"; + // Apple platform binaries hide their environment from KERN_PROCARGS2. + let sleeper = base.join("sleep"); + fs::copy("/bin/sleep", &sleeper).expect("should copy a visible sleeper"); + let mut carrier = std::process::Command::new(&sleeper) + .arg("30") + .env("TS_RELEASE_BOUNDARY_RUN", nonce) + .spawn() + .expect("should start an own nonce carrier"); + let carrier_pid = libc::pid_t::try_from(carrier.id()).expect("should fit a pid"); + std::thread::sleep(Duration::from_millis(100)); + let signature = |started_after_micros| RunSignature { + variable: "TS_RELEASE_BOUNDARY_RUN", + nonce, + allow: &allow, + deny: &deny, + started_after_micros, + }; + let future = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|now| now + Duration::from_secs(3600)) + .ok() + .and_then(|future| u64::try_from(future.as_micros()).ok()); + let eligible = matching(&signature(0)); + let later = future.map(|future| matching(&signature(future))); + // Stop the own carrier before any assertion can unwind. + carrier.kill().expect("should stop own carrier"); + carrier.wait().expect("should reap own carrier"); + let eligible = eligible.expect("should list eligible carriers"); + let later = later + .expect("should compute a later run start") + .expect("should list carriers started after a later run"); + assert_eq!( + eligible, + vec![carrier_pid], + "should match exactly the own carrier" + ); + assert!( + later.is_empty(), + "should never match a process that started before the run: {later:?}" + ); +} + +#[test] +fn concurrent_run_of_another_instance_is_not_reaped() { + let first = Fixture::new(); + let first_proof = first.prove(); + let second = Fixture::new(); + let second_proof = second.prove(); + std::thread::scope(|scope| { + let long = scope.spawn(|| { + second + .boundary + .run_confined(&second.command("sleep", &["1500"]), &second_proof) + }); + std::thread::sleep(Duration::from_millis(300)); + let short = first + .boundary + .run_confined(&first.command("sleep", &["0"]), &first_proof) + .expect("should not count another instance's live run as a survivor"); + assert_eq!( + short.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should complete the short run" + ); + let long = long + .join() + .expect("should join the concurrent run") + .expect("should leave the other instance's run untouched"); + assert_eq!( + long.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should let the other instance finish normally" + ); + }); +} + +#[test] +fn escaped_scrubbed_descendant_inherits_denial() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + assert_eq!( + fixture.run(&proof, "escape-connect", &[&address]), + "denied errno=1", + "should deny a new-group descendant with a scrubbed environment" + ); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 0, + "should see no accept from the escaped descendant" + ); +} + +#[test] +fn runtime_hard_link_from_immutable_root_is_denied() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let source = fixture.immutable.join("src/a.txt"); + let alias = fixture.writable.join("runtime-link"); + assert_eq!( + fixture.run(&proof, "link", &[path_text(&source), path_text(&alias)]), + "denied errno=1", + "should deny creating a write-through alias during the run" + ); + assert!(!alias.exists(), "should leave no alias behind"); + assert_eq!( + fs::metadata(&source).expect("should stat source").nlink(), + 1, + "should keep the immutable file single-linked" + ); +} + +#[test] +fn pre_existing_hard_link_is_rejected_before_execution() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let source = fixture.immutable.join("src/a.txt"); + let alias = fixture.writable.join("prelink"); + fs::hard_link(&source, &alias).expect("should hard link into the private root"); + + let rejected = fixture + .boundary + .run_confined(&fixture.command("write", &[path_text(&alias)]), &proof) + .expect_err("should reject the hard-linked immutable file"); + assert!( + format!("{rejected:?}").contains("hard links"), + "should name the hard link" + ); + assert_eq!( + fs::read(&source).expect("should read"), + b"original", + "should not run the helper" + ); +} + +#[test] +fn hard_linked_tool_is_rejected_before_execution() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + fs::hard_link(fixture.probe.program(), fixture.writable.join("tool-alias")) + .expect("should hard link the tool"); + let rejected = fixture + .boundary + .run_confined(&fixture.command("sleep", &["0"]), &proof) + .expect_err("should reject a hard-linked tool"); + assert!( + format!("{rejected:?}").contains("hard links"), + "should name the hard link" + ); +} + +#[test] +fn hard_link_write_through_bypasses_path_denial_and_restoration_hides_it() { + let fixture = Fixture::new(); + let source = fixture.immutable.join("src/a.txt"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o444)).expect("should chmod"); + let before = fs::metadata(&source).expect("should stat source"); + let alias = fixture.writable.join("prelink"); + fs::hard_link(&source, &alias).expect("should hard link"); + fs::set_permissions(&alias, fs::Permissions::from_mode(0o644)).expect("should chmod alias"); + + // Deliberately skip the boundary's nlink scan to show what it prevents. + let profile = MacProfile::generate(&fixture.boundary.roots).expect("should generate"); + let run = RunIdentity::new(&profile).expect("should create run markers"); + let result = profile + .wrap(&fixture.command("write", &[path_text(&alias)]), &run) + .expect("should wrap") + .run() + .expect("should run unscanned helper"); + assert_eq!( + helper_report(result.stdout()), + "written", + "should write through the link" + ); + assert_eq!( + fs::read(&source).expect("should read"), + b"tampered", + "should reach the source" + ); + + restore_and_compare(&source, &alias, &before); +} + +/// Restores content, mtime and mode through `alias` and shows the source +/// then matches its pre-tamper content, mode and mtime. +fn restore_and_compare(source: &Path, alias: &Path, before: &fs::Metadata) { + fs::write(alias, b"original").expect("should restore content"); + let modified = before.modified().expect("should read mtime"); + fs::File::options() + .write(true) + .open(alias) + .and_then(|file| file.set_modified(modified)) + .expect("should restore mtime"); + fs::set_permissions(source, fs::Permissions::from_mode(0o444)).expect("should restore mode"); + let after = fs::metadata(source).expect("should stat source"); + assert_eq!( + fs::read(source).expect("should read"), + b"original", + "should match content" + ); + assert_eq!( + after.permissions(), + before.permissions(), + "should match mode" + ); + assert_eq!(after.modified().ok(), Some(modified), "should match mtime"); +} + +/// Mutates `path` from an unconfined thread while a confined helper sleeps. +fn mutate_during_run(fixture: &Fixture, proof: &DenialProof, path: PathBuf) -> String { + let started = SystemTime::now(); + let mutator = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755)); + fs::write(&path, b"mutated").expect("should mutate outside the sandbox"); + }); + let outcome = fixture + .boundary + .run_confined(&fixture.command("sleep", &["1500"]), proof); + mutator.join().expect("should join mutator"); + let elapsed = started.elapsed().expect("should measure"); + assert!( + elapsed >= Duration::from_millis(300), + "should overlap the mutation" + ); + format!("{:?}", outcome.expect_err("should reject a changed root")) +} + +#[test] +fn immutable_and_tool_mutation_during_run_is_detected() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let error = mutate_during_run(&fixture, &proof, fixture.immutable.join("src/a.txt")); + assert!( + error.contains("changed"), + "should report the immutable change" + ); + + let fixture = Fixture::new(); + let proof = fixture.prove(); + let tool = fixture + .probe + .program() + .parent() + .expect("should have tool root") + .join("extra-tool"); + let error = mutate_during_run(&fixture, &proof, tool); + assert!( + error.contains("changed"), + "should report the tool-root change" + ); +} + +#[test] +fn unsafe_writable_alias_left_by_a_run_is_rejected() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let writable = fixture.writable.clone(); + let creator = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + symlink("/etc", writable.join("absolute-alias")).expect("should create alias"); + }); + let outcome = fixture + .boundary + .run_confined(&fixture.command("sleep", &["1000"]), &proof); + creator.join().expect("should join creator"); + let error = outcome.expect_err("should reject an unsafe alias after the run"); + assert!( + format!("{error:?}").contains("absolute"), + "should name the alias" + ); +} + +#[test] +fn proof_is_bound_to_its_instance() { + let first = Fixture::new(); + let proof = first.prove(); + let twin = Boundary::mac_sandbox_for_tests(first.boundary.roots.clone()) + .expect("should configure an identical twin"); + let error = twin + .run_confined(&first.command("sleep", &["0"]), &proof) + .expect_err("should reject a proof from another instance"); + assert!( + format!("{error:?}").contains("different boundary"), + "should explain" + ); + let result = first + .boundary + .run_confined(&first.command("sleep", &["0"]), &proof) + .expect("should accept its own proof"); + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should run" + ); +} + +#[test] +fn programs_outside_the_roots_are_rejected() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let command = ConfinedCommand::new(Path::new("/usr/bin/true"), &fixture.writable, limits()) + .expect("should describe command"); + assert!( + fixture.boundary.run_confined(&command, &proof).is_err(), + "should reject a program outside every root" + ); +} + +/// Reads the umask a helper inherits from this process, outside any sandbox. +fn unconfined_umask() -> String { + let mut command = StdCommand::new(test_binary()); + command + .args(helper_arguments(&[])) + .env_clear() + .stdin(std::process::Stdio::null()); + for (name, value) in helper_environment("umask") { + command.env(name, value); + } + let output = command.output().expect("should run the unconfined helper"); + helper_report(&output.stdout) +} + +#[test] +fn confined_command_reports_the_requested_umask() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let report = |umask: Option| { + let mut command = fixture.command("umask", &[]); + if let Some(mask) = umask { + command = command.umask(Umask::new(mask).expect("should accept mask")); + } + let result = fixture + .boundary + .run_confined(&command, &proof) + .expect("should run confined helper"); + helper_report(result.stdout()) + }; + assert_eq!( + report(None), + unconfined_umask(), + "should inherit the orchestrator umask without a request" + ); + assert_eq!(report(Some(0o027)), "027", "should apply umask 027"); + assert_eq!(report(Some(0o077)), "077", "should apply umask 077"); +} diff --git a/crates/trusted-server-release/src/boundary/tests/modes.rs b/crates/trusted-server-release/src/boundary/tests/modes.rs new file mode 100644 index 000000000..e10c4fc30 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/modes.rs @@ -0,0 +1,340 @@ +//! Helper child modes run by [`helper_entry`](super::helper_entry). +//! +//! Every mode prints one report line after the sentinel: a success word, or +//! `denied errno=` with the operating system's real errno (exit 3). + +use std::io::Write as _; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::os::unix::process::CommandExt as _; +use std::process::Stdio; + +use super::*; + +/// Prints `line` after the sentinel and returns `code`. +fn report(line: &str, code: i32) -> i32 { + let mut stdout = std::io::stdout(); + let mut bytes = SENTINEL.to_vec(); + bytes.extend_from_slice(line.as_bytes()); + bytes.push(b'\n'); + if stdout + .write_all(&bytes) + .and_then(|()| stdout.flush()) + .is_err() + { + return HELPER_SETUP_FAILED; + } + code +} + +/// Reports `success`, or the real errno with the denied status. +fn report_errno(success: &str, errno: Option) -> i32 { + match errno { + None => report(success, 0), + Some(errno) => report(&format!("denied errno={errno}"), i32::from(DENIED_STATUS)), + } +} + +/// Returns the errno of a failed libc call (`rc < 0`). +fn call_errno(rc: libc::c_int) -> Option { + (rc < 0).then(|| std::io::Error::last_os_error().raw_os_error().unwrap_or(-1)) +} + +/// Runs `mode` with its `arguments`, returning the exit status. +pub(super) fn run(mode: &str, arguments: &[String]) -> i32 { + let argument = |index: usize| arguments.get(index).map(String::as_str).unwrap_or_default(); + match mode { + "connect" => connect(argument(0)), + "nest" => nest(argument(0), argument(1), argument(2)), + "write" => report_errno( + "written", + std::fs::write(argument(0), b"tampered") + .err() + .map(|error| error.raw_os_error().unwrap_or(-1)), + ), + "signal" => signal(argument(0), argument(1)), + "signal-own-child" => signal_own_child(), + #[cfg(target_os = "linux")] + "process-memory-read" => process_memory_read(), + "wait-for-eof" => match std::io::copy(&mut std::io::stdin(), &mut std::io::sink()) { + Ok(_) => 0, + Err(_) => HELPER_SETUP_FAILED, + }, + "sleep" => { + std::thread::sleep(Duration::from_millis(argument(0).parse().unwrap_or(0))); + 0 + } + // SAFETY: `setsid` and `setpgid` have no memory-safety preconditions. + "setsid" => report_errno("detached", call_errno(unsafe { libc::setsid() })), + // SAFETY: as above. + "setpgid" => report_errno("detached", call_errno(unsafe { libc::setpgid(0, 0) })), + "orphan" => leave_child(argument(0), Departure::SameGroup), + "escape" => leave_child( + argument(0), + Departure::NewGroup { + scrub: argument(1) == "scrub", + }, + ), + "link" => report_errno( + "linked", + std::fs::hard_link(argument(0), argument(1)) + .err() + .map(|error| error.raw_os_error().unwrap_or(-1)), + ), + "escape-connect" => escape_connect(argument(0)), + "unix-exchange" => unix_exchange(argument(0)), + "socket" => socket(argument(0)), + "umask" => umask(), + _ => HELPER_SETUP_FAILED, + } +} + +/// Attempts a valid self-memory read, which the confined filter must deny. +#[cfg(target_os = "linux")] +pub(super) fn process_memory_read() -> i32 { + let remote = [0x5a_u8]; + let mut local = [0_u8]; + let local = libc::iovec { + iov_base: local.as_mut_ptr().cast(), + iov_len: 1, + }; + let remote = libc::iovec { + iov_base: remote.as_ptr().cast_mut().cast(), + iov_len: 1, + }; + // SAFETY: Both one-byte buffers remain live, and their iovecs describe + // valid memory in this process for the duration of the synchronous call. + let result = unsafe { libc::process_vm_readv(libc::getpid(), &local, 1, &remote, 1, 0) }; + if result == 1 { + report("accessed", 0) + } else if result < 0 { + report_errno("accessed", std::io::Error::last_os_error().raw_os_error()) + } else { + HELPER_SETUP_FAILED + } +} + +fn connect(address: &str) -> i32 { + let Ok(address) = parse_loopback(address) else { + return HELPER_SETUP_FAILED; + }; + match connect_probe(address) { + Ok(ProbeReport::Connected) => report("connected", 0), + Ok(ProbeReport::Denied { errno }) => report_errno("", Some(errno)), + Err(_) => HELPER_SETUP_FAILED, + } +} + +/// Like [`helper_environment`] but reusing the inherited nonce. +fn inherited_helper_environment(mode: &str) -> Vec<(OsString, OsString)> { + let inherited = std::env::var(HELPER_NONCE).unwrap_or_default(); + vec![ + ( + OsString::from(HELPER_SELECTOR), + OsString::from(format!("{mode}:{inherited}")), + ), + (OsString::from(HELPER_NONCE), OsString::from(inherited)), + ] +} + +/// Returns a command re-running this helper in `mode`. +fn self_command(mode: &str, mode_arguments: &[&str]) -> Option { + let mut command = StdCommand::new(std::env::current_exe().ok()?); + command.args(helper_arguments(mode_arguments)); + for (name, value) in inherited_helper_environment(mode) { + command.env(name, value); + } + Some(command) +} + +/// Records a marker in `marker_directory`, then connects directly at depth +/// zero or through a child helper at depth `n - 1`. +fn nest(depth: &str, address: &str, marker_directory: &str) -> i32 { + let Ok(depth) = depth.parse::() else { + return HELPER_SETUP_FAILED; + }; + let marker = Path::new(marker_directory).join(format!("nest-{depth}")); + if std::fs::write(marker, std::process::id().to_string()).is_err() { + return HELPER_SETUP_FAILED; + } + if depth == 0 { + return connect(address); + } + let next = (depth - 1).to_string(); + let Some(mut child) = self_command("nest", &[&next, address, marker_directory]) + .and_then(|mut command| command.spawn().ok()) + else { + return HELPER_SETUP_FAILED; + }; + child + .wait() + .ok() + .and_then(|status| status.code()) + .unwrap_or(HELPER_SETUP_FAILED) +} + +fn kill_errno(pid: libc::pid_t, signal: libc::c_int) -> Option { + // SAFETY: `kill` has no memory-safety preconditions; callers never pass + // a pid <= 1. + call_errno(unsafe { libc::kill(pid, signal) }) +} + +fn signal(pid: &str, signal: &str) -> i32 { + let (Ok(pid), Ok(signal)) = (pid.parse::(), signal.parse::()) else { + return HELPER_SETUP_FAILED; + }; + if pid <= 1 { + return HELPER_SETUP_FAILED; + } + report_errno("signalled", kill_errno(pid, signal)) +} + +fn signal_own_child() -> i32 { + let Some(mut child) = + self_command("sleep", &["30000"]).and_then(|mut command| command.spawn().ok()) + else { + return HELPER_SETUP_FAILED; + }; + let Ok(pid) = libc::pid_t::try_from(child.id()) else { + return HELPER_SETUP_FAILED; + }; + let error = kill_errno(pid, libc::SIGKILL); + if error.is_some() { + let _ = child.kill(); + } + let _ = child.wait(); + report_errno("signalled", error) +} + +/// How a helper's child leaves it. +#[derive(Clone, Copy)] +enum Departure { + /// A daemon-style orphan that stays in the supervised process group. + SameGroup, + /// A `posix_spawn(POSIX_SPAWN_SETPGROUP)` child in a new group, keeping + /// or scrubbing the inherited environment. + NewGroup { scrub: bool }, +} + +/// Spawns a sleeping child with null stdio, records its pid and exits +/// without waiting for it. +fn leave_child(pid_file: &str, departure: Departure) -> i32 { + let Some(mut command) = self_command("sleep", &["30000"]) else { + return HELPER_SETUP_FAILED; + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Departure::NewGroup { scrub } = departure { + command.process_group(0); + if scrub { + command.env_clear(); + for (name, value) in inherited_helper_environment("sleep") { + command.env(name, value); + } + } + } + let Ok(child) = command.spawn() else { + return HELPER_SETUP_FAILED; + }; + if std::fs::write(pid_file, child.id().to_string()).is_err() { + return HELPER_SETUP_FAILED; + } + report("spawned", 0) +} + +/// Runs `connect` in a `posix_spawn(POSIX_SPAWN_SETPGROUP)` child whose +/// environment keeps only the helper selector, and waits for its report. +fn escape_connect(address: &str) -> i32 { + let Some(mut command) = self_command("connect", &[address]) else { + return HELPER_SETUP_FAILED; + }; + command.process_group(0).env_clear(); + for (name, value) in inherited_helper_environment("connect") { + command.env(name, value); + } + command + .status() + .ok() + .and_then(|status| status.code()) + .unwrap_or(HELPER_SETUP_FAILED) +} + +/// Binds a Unix socket at `path`, connects to it and echoes four bytes. +fn unix_exchange(path: &str) -> i32 { + let errno = |error: std::io::Error| Some(error.raw_os_error().unwrap_or(-1)); + let listener = match UnixListener::bind(path) { + Ok(listener) => listener, + Err(error) => return report_errno("", errno(error)), + }; + let server = std::thread::spawn(move || -> std::io::Result<()> { + let (mut stream, _) = listener.accept()?; + let mut bytes = [0_u8; 4]; + std::io::Read::read_exact(&mut stream, &mut bytes)?; + stream.write_all(&bytes) + }); + let exchanged = UnixStream::connect(path).and_then(|mut stream| { + stream.write_all(b"ping")?; + let mut bytes = [0_u8; 4]; + std::io::Read::read_exact(&mut stream, &mut bytes)?; + Ok(bytes) + }); + // The socket file is removed so the run leaves no special file behind + // in the writable root; the helper `_exit`s, so an unjoined server + // thread blocked in `accept` cannot hang it. + let removed = std::fs::remove_file(path).is_ok(); + let bytes = match exchanged { + Ok(bytes) => bytes, + Err(error) => return report_errno("", errno(error)), + }; + match server.join() { + Ok(Ok(())) if removed && &bytes == b"ping" => report("exchanged", 0), + _ => HELPER_SETUP_FAILED, + } +} + +/// Maps a fixture domain name to `(domain, type)`. +fn socket_kind(domain: &str) -> Option<(libc::c_int, libc::c_int)> { + match domain { + "unix" => Some((libc::AF_UNIX, libc::SOCK_STREAM)), + "inet" => Some((libc::AF_INET, libc::SOCK_STREAM)), + "inet6" => Some((libc::AF_INET6, libc::SOCK_STREAM)), + #[cfg(target_os = "linux")] + "netlink" => Some((libc::AF_NETLINK, libc::SOCK_RAW)), + #[cfg(target_os = "linux")] + "packet" => Some((libc::AF_PACKET, libc::SOCK_RAW)), + _ => None, + } +} + +/// Creates (and closes) one socket of `domain`. +fn socket(domain: &str) -> i32 { + let Some((domain, kind)) = socket_kind(domain) else { + return HELPER_SETUP_FAILED; + }; + // SAFETY: `socket` has no memory-safety preconditions. + let fd = unsafe { libc::socket(domain, kind, 0) }; + let errno = call_errno(fd); + if errno.is_none() { + // SAFETY: `fd` is a descriptor this helper just created. + unsafe { + libc::close(fd); + } + } + report_errno("created", errno) +} + +/// Reports this process's umask as three octal digits. +/// +/// Reading requires setting; the previous value is restored at once, and the +/// helper is single-threaded. +fn umask() -> i32 { + // SAFETY: `umask` has no memory-safety preconditions and cannot fail; the + // second call restores the value the first call returned. + let current = unsafe { + let current = libc::umask(0); + libc::umask(current); + current + }; + report(&format!("{current:03o}"), 0) +} diff --git a/crates/trusted-server-release/src/boundary/tests/unit.rs b/crates/trusted-server-release/src/boundary/tests/unit.rs new file mode 100644 index 000000000..0406c88fc --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/unit.rs @@ -0,0 +1,560 @@ +//! Platform-independent boundary tests on real files and processes. + +use std::fs; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; + +use tempfile::{TempDir, tempdir}; + +use super::super::macos::{MacProfile, SYS_SETPGID, SYS_SETSID, define}; +use super::super::probe::LoopbackListener; +use super::super::roots::validate_path_text; +use super::*; +use crate::process::{BoundedCommand, ProcessOutcome}; + +pub(super) fn canonical_tempdir() -> (TempDir, PathBuf) { + let directory = tempdir().expect("should create temporary directory"); + let canonical = fs::canonicalize(directory.path()).expect("should canonicalize tempdir"); + (directory, canonical) +} + +fn error_text(report: &Report) -> String { + format!("{report:?}") +} + +// --------------------------------------------------------------------------- +// Probe protocol +// --------------------------------------------------------------------------- + +#[test] +fn probe_report_round_trips_and_rejects_malformed_lines() { + for report in [ProbeReport::Connected, ProbeReport::Denied { errno: 1 }] { + let line = format!("{report}\n"); + assert_eq!( + ProbeReport::parse_line(line.as_bytes()), + Some(report), + "should round-trip {report}" + ); + } + for malformed in [ + &b"connected"[..], + b"denied errno=\n", + b"denied errno=-1\n", + b"denied errno=1 \n", + b"connected\nconnected\n", + b"refused\n", + ] { + assert_eq!( + ProbeReport::parse_line(malformed), + None, + "should reject {malformed:?}" + ); + } +} + +#[test] +fn probe_address_must_be_literal_loopback() { + for accepted in ["127.0.0.1:80", "[::1]:443"] { + assert!(parse_loopback(accepted).is_ok(), "should accept {accepted}"); + } + for rejected in [ + "10.0.0.1:80", + "192.0.2.1:80", + "localhost:80", + "127.0.0.1:0", + "127.0.0.1", + ] { + assert!( + parse_loopback(rejected).is_err(), + "should reject {rejected}" + ); + } +} + +#[test] +fn unconfined_control_connects_and_listener_observes_accept() { + let listener = LoopbackListener::bind().expect("should bind listener"); + let probe = helper_probe(&test_binary()); + let mut command = BoundedCommand::new(probe.program(), Path::new("/"), limits()) + .expect("should describe control") + .arguments(probe.arguments(listener.address())); + for (name, value) in probe.environment() { + command = command.environment(name, value).expect("should set env"); + } + let result = command.run().expect("should run unconfined control"); + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should exit 0 when connected" + ); + assert_eq!( + probe.parse(result.stdout()), + Some(ProbeReport::Connected), + "should report connected" + ); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 1, + "should observe exactly one accept" + ); +} + +// --------------------------------------------------------------------------- +// Roots, SBPL template and tree facts +// --------------------------------------------------------------------------- + +#[test] +fn path_text_rejects_profile_metacharacters() { + assert!(validate_path_text(Path::new("/private/tmp/ok-root")).is_ok()); + let nul = PathBuf::from(std::ffi::OsStr::from_bytes(b"/tmp/a\0b")); + for rejected in [ + PathBuf::from("/tmp/quote\"root"), + PathBuf::from("/tmp/back\\slash"), + PathBuf::from("/tmp/new\nline"), + PathBuf::from("/tmp/tab\tchar"), + nul, + PathBuf::from("relative/root"), + PathBuf::from("/"), + ] { + assert!( + validate_path_text(&rejected).is_err(), + "should reject {rejected:?}" + ); + assert!( + define("WRITABLE_0", &rejected).is_err(), + "should reject {rejected:?} as an SBPL define" + ); + } +} + +#[test] +fn roots_reject_metacharacter_directories_and_overlap() { + let (_directory, base) = canonical_tempdir(); + for name in ["quote\"dir", "back\\dir", "new\ndir"] { + let path = base.join(name); + fs::create_dir(&path).expect("should create odd directory"); + assert!( + BoundaryRoots::new(&[], &[], &[path]).is_err(), + "should reject root named {name:?}" + ); + } + let outer = base.join("outer"); + let inner = outer.join("inner"); + fs::create_dir_all(&inner).expect("should create nested roots"); + assert!( + BoundaryRoots::new( + std::slice::from_ref(&inner), + &[], + std::slice::from_ref(&outer) + ) + .is_err(), + "should reject nested roots" + ); + assert!( + BoundaryRoots::new( + std::slice::from_ref(&outer), + &[], + std::slice::from_ref(&outer) + ) + .is_err(), + "should reject duplicate roots" + ); + assert!( + BoundaryRoots::new(&[outer], &[], &[]).is_err(), + "should require a writable root" + ); + let many: Vec = (0..=super::super::roots::MAX_ROOTS) + .map(|index| { + let path = base.join(format!("many-{index}")); + fs::create_dir(&path).expect("should create root"); + path + }) + .collect(); + assert!( + BoundaryRoots::new(&[], &[], &many).is_err(), + "should reject more roots than profile slots" + ); +} + +pub(super) fn sample_roots(base: &Path) -> BoundaryRoots { + let immutable = base.join("immutable"); + let writable = base.join("writable"); + let tool = base.join("tool"); + for path in [&immutable, &writable] { + fs::create_dir(path).expect("should create root"); + } + fs::write(&tool, b"tool").expect("should create tool"); + BoundaryRoots::new(&[immutable], &[tool], &[writable]).expect("should accept roots") +} + +#[test] +fn sbpl_profile_hides_ambient_openssl_configuration_after_root_grants() { + let (_directory, base) = canonical_tempdir(); + let profile = MacProfile::generate(&sample_roots(&base)).expect("should generate profile"); + let rule = "(deny file-read* (with errno ENOENT)\n (literal \"/private/etc/ssl/openssl.cnf\")\n (literal \"/etc/ssl/openssl.cnf\"))"; + let denial = profile + .text() + .find(rule) + .expect("should hide both exact aliases"); + let grant = profile + .text() + .rfind("(allow file-read*") + .expect("should have root grants"); + assert!(denial > grant, "should override every file read grant"); + assert!( + !profile.text().contains("(subpath \"/private/etc/ssl\")") + && !profile.text().contains("(subpath \"/etc/ssl\")"), + "should not grant access to ambient SSL configuration" + ); +} + +#[test] +fn sbpl_profile_carries_paths_only_as_defines() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let profile = MacProfile::generate(&roots).expect("should generate profile"); + let base_text = base.to_str().expect("should be UTF-8"); + assert!( + !profile.text().contains(base_text), + "should never splice root paths into profile text" + ); + for required in [ + "(deny default)", + "(deny network*)", + "(allow signal (target same-sandbox))", + "(deny file-write* (subpath (param \"IMMUTABLE_0\")))", + "(allow file-read* file-write* process-exec (subpath (param \"WRITABLE_0\")))", + ] { + assert!( + profile.text().contains(required), + "should contain {required}" + ); + } + let names: Vec<&str> = profile + .defines() + .iter() + .map(|(name, _)| name.as_str()) + .collect(); + assert_eq!( + names, + ["IMMUTABLE_0", "TOOL_0", "WRITABLE_0"], + "should bind every slot" + ); + let arguments = profile.arguments(); + assert_eq!( + arguments.first(), + Some(&OsString::from("-p")), + "should pass text with -p" + ); + assert_eq!( + arguments + .iter() + .filter(|argument| *argument == "-D") + .count(), + 3, + "should pass one define per root" + ); +} + +#[test] +fn sbpl_fingerprint_depends_on_roots() { + let (_first_directory, first) = canonical_tempdir(); + let (_second_directory, second) = canonical_tempdir(); + let first = MacProfile::generate(&sample_roots(&first)).expect("should generate"); + let second = MacProfile::generate(&sample_roots(&second)).expect("should generate"); + assert_ne!( + first.fingerprint(), + second.fingerprint(), + "should bind root paths" + ); + assert_eq!( + first.fingerprint(), + first.clone().fingerprint(), + "should be stable" + ); +} + +#[test] +fn tree_scan_rejects_hard_links_and_unsafe_aliases() { + let (_directory, base) = canonical_tempdir(); + let root = base.join("root"); + fs::create_dir_all(root.join("sub")).expect("should create root"); + fs::write(root.join("sub/file.txt"), b"source").expect("should write file"); + symlink("sub/file.txt", root.join("inside")).expect("should create safe link"); + TreeFacts::scan(&root, ScanPolicy::Immutable).expect("should accept a safe tree"); + + let hard = base.join("outside-link"); + fs::hard_link(root.join("sub/file.txt"), &hard).expect("should hard link"); + let rejected = + TreeFacts::scan(&root, ScanPolicy::Immutable).expect_err("should reject nlink 2"); + assert!( + error_text(&rejected).contains("hard links"), + "should name the hard link" + ); + fs::remove_file(&hard).expect("should remove link"); + + for (name, target) in [ + ("absolute", "/etc/hosts"), + ("escaping", "../../outside"), + ("dangling", "sub/missing"), + ] { + let link = root.join(name); + symlink(target, &link).expect("should create link"); + for policy in [ScanPolicy::Immutable, ScanPolicy::AliasesOnly] { + assert!( + TreeFacts::scan(&root, policy).is_err(), + "should reject {name} alias under {policy:?}" + ); + } + fs::remove_file(&link).expect("should remove link"); + } + let fifo = root.join("fifo"); + let fifo_text = + std::ffi::CString::new(fifo.as_os_str().as_bytes()).expect("should be C string"); + // SAFETY: `fifo_text` is a valid NUL-terminated path. + assert_eq!( + unsafe { libc::mkfifo(fifo_text.as_ptr(), 0o600) }, + 0, + "should create fifo" + ); + assert!( + TreeFacts::scan(&root, ScanPolicy::AliasesOnly).is_err(), + "should reject special files" + ); +} + +#[test] +fn tree_facts_detect_content_mode_and_membership_changes() { + let (_directory, base) = canonical_tempdir(); + let file = base.join("file.txt"); + fs::write(&file, b"one").expect("should write file"); + let before = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should scan"); + assert!( + before + .ensure_unchanged(&TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should scan")) + .is_ok(), + "should accept an unchanged tree" + ); + let mutations: [&dyn Fn(); 3] = [ + &|| fs::write(&file, b"two").expect("should rewrite"), + &|| { + fs::set_permissions(&file, fs::Permissions::from_mode(0o400)).expect("should chmod"); + }, + &|| fs::write(base.join("added"), b"x").expect("should add"), + ]; + for mutate in mutations { + let snapshot = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should scan"); + mutate(); + let after = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should rescan"); + assert!( + snapshot.ensure_unchanged(&after).is_err(), + "should detect mutation" + ); + } + let snapshot = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should scan"); + fs::remove_file(base.join("added")).expect("should remove"); + let after = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should rescan"); + assert!( + snapshot.ensure_unchanged(&after).is_err(), + "should detect removal" + ); +} + +#[test] +fn confined_command_environment_rejects_injection_and_duplicates() { + let command = ConfinedCommand::new(Path::new("/bin/tool"), Path::new("/work"), limits()) + .expect("should describe command"); + let command = command + .environment("HOME", "/work") + .expect("should accept HOME"); + assert!( + ConfinedCommand::new(Path::new("/bin/tool"), Path::new("/work"), limits()) + .expect("should describe command") + .environment("LD_PRELOAD", "/x.so") + .is_err(), + "should reject injection variables" + ); + assert!( + command.environment("HOME", "/other").is_err(), + "should reject duplicates" + ); + assert!( + ConfinedCommand::new(Path::new("relative"), Path::new("/work"), limits()).is_err(), + "should reject a relative program" + ); +} + +#[test] +fn macos_boundary_is_test_only_until_qualified() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + assert_eq!( + Boundary::mac_sandbox_for_tests(roots).is_ok(), + cfg!(target_os = "macos"), + "should allow sandbox-exec only on macOS" + ); +} + +#[test] +fn instance_fingerprints_differ_for_identical_configurations() { + let platform = [9; 32]; + let first = instance_fingerprint(&platform).expect("should draw a nonce"); + let second = instance_fingerprint(&platform).expect("should draw a nonce"); + assert_ne!( + first, second, + "should bind a fresh nonce into every instance" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn boundary_instances_never_share_a_fingerprint() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let first = Boundary::mac_sandbox_for_tests(roots.clone()).expect("should configure"); + let second = Boundary::mac_sandbox_for_tests(roots).expect("should configure"); + assert_ne!( + first.fingerprint, second.fingerprint, + "should bind a fresh nonce into every instance" + ); +} + +#[test] +fn confined_paths_reject_dot_components() { + for (program, working_directory) in [ + ("/tools/../bin/tool", "/work"), + ("/tools/./tool", "/work"), + ("/tools/tool", "/work/../etc"), + ("/tools/tool", "/work/."), + ("tools/tool", "/work"), + ] { + assert!( + ConfinedCommand::new(Path::new(program), Path::new(working_directory), limits()) + .is_err(), + "should reject {program} in {working_directory}" + ); + } +} + +#[test] +fn tool_roots_require_single_link_files_and_staging_provides_them() { + let (_directory, base) = canonical_tempdir(); + let tools = base.join("tools"); + fs::create_dir(&tools).expect("should create tools"); + let staged = staged_probe(&tools); + let metadata = fs::metadata(staged.program()).expect("should stat staged probe"); + assert_eq!(metadata.nlink(), 1, "should stage a single-link copy"); + assert_eq!(metadata.mode() & 0o777, 0o555, "should stage read-only"); + TreeFacts::scan(&tools, ScanPolicy::Immutable).expect("should accept the staged tool"); + TreeFacts::scan(staged.program(), ScanPolicy::Immutable).expect("should scan a file root"); + assert!( + helper_probe(&test_binary()).staged(&tools).is_err(), + "should refuse to overwrite a staged tool" + ); + fs::hard_link(staged.program(), base.join("alias")).expect("should hard link"); + assert!( + TreeFacts::scan(&tools, ScanPolicy::Immutable).is_err(), + "should reject a hard-linked tool" + ); + assert!( + TreeFacts::scan(staged.program(), ScanPolicy::Immutable).is_err(), + "should reject a hard-linked file root" + ); +} + +#[test] +fn writable_scans_reject_hard_links_before_runs() { + let (_directory, base) = canonical_tempdir(); + let writable = base.join("writable"); + fs::create_dir(&writable).expect("should create writable root"); + fs::write(writable.join("file"), b"x").expect("should write"); + TreeFacts::scan(&writable, ScanPolicy::SingleLinks).expect("should accept single links"); + fs::hard_link(writable.join("file"), base.join("outside")).expect("should hard link"); + assert!( + TreeFacts::scan(&writable, ScanPolicy::SingleLinks).is_err(), + "should reject a hard link before a run" + ); + TreeFacts::scan(&writable, ScanPolicy::AliasesOnly).expect("should allow links after a run"); +} + +#[test] +fn sbpl_profile_denies_group_escape_and_scopes_unix_sockets() { + let (_directory, base) = canonical_tempdir(); + let profile = MacProfile::generate(&sample_roots(&base)).expect("should generate"); + let text = profile.text(); + let syscalls = format!("(deny syscall-unix (syscall-number {SYS_SETSID} {SYS_SETPGID}))"); + assert!(text.contains(&syscalls), "should deny setsid and setpgid"); + let network = text.find("(deny network*)").expect("should deny network"); + let unix = text + .find("(local unix-socket (subpath (param \"WRITABLE_0\")))") + .expect("should allow Unix sockets under the writable root"); + assert!( + unix > network, + "should re-allow only Unix sockets after the denial" + ); + assert!( + !text.contains("unix-socket (subpath (param \"IMMUTABLE_0\"))"), + "should not allow Unix sockets in immutable roots" + ); + for absent in ["(subpath \"/usr\")", "/usr/local"] { + assert!(!text.contains(absent), "should not allow {absent}"); + } + assert!( + text.ends_with( + "(allow file-read-data (literal (param \"RUN_MARK_ALLOW\")))\n\ + (deny file-read* (literal (param \"RUN_MARK_DENY\")))\n" + ), + "should end with the run instance marker rules so that they take precedence" + ); +} + +#[test] +fn sibling_lifetime_is_owned_by_the_parent_pipe() { + let mut sibling = spawn_sibling(); + let controlled = sibling.stdin.is_some(); + if controlled { + drop(sibling.stdin.take()); + } else { + sibling + .kill() + .expect("should stop the old timer-based fixture"); + } + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = sibling.try_wait().expect("should poll sibling") { + break Some(status); + } + if Instant::now() >= deadline { + sibling.kill().expect("should stop a failed fixture"); + sibling.wait().expect("should reap a failed fixture"); + break None; + } + std::thread::sleep(Duration::from_millis(10)); + }; + assert!(controlled, "should retain a parent-owned lifetime pipe"); + assert!( + status.is_some_and(|status| status.success()), + "should exit successfully when the parent closes the pipe" + ); +} + +#[test] +fn confined_debug_does_not_expose_private_stdin() { + let private = b"fictional-private-parent-input"; + let command = ConfinedCommand::new( + Path::new("/example/tool"), + Path::new("/example/work"), + limits(), + ) + .expect("should describe command") + .stdin_bytes(private.to_vec()); + let debug = format!("{command:?}"); + assert!( + !debug.contains("fictional-private-parent-input"), + "should omit private stdin from diagnostics" + ); + assert!( + !debug.contains("102, 105, 99, 116"), + "should not render private stdin as decimal bytes" + ); +} diff --git a/crates/trusted-server-release/src/composition.rs b/crates/trusted-server-release/src/composition.rs new file mode 100644 index 000000000..c2ca4bcf7 --- /dev/null +++ b/crates/trusted-server-release/src/composition.rs @@ -0,0 +1,500 @@ +//! Observed Cargo compilation units, without granting material or release authority. +//! +//! Metadata describes package identities and potential graph membership. Only +//! compiler messages describe compiled units and their effective features. +//! Native output validation and final executable membership are separate gates. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; + +use error_stack::Report; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::documents::{Role, parse_json}; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; +use crate::recipe::observed_cargo_messages; + +mod capture; +pub(crate) mod external; +pub(crate) mod graph; +pub(crate) mod javascript; +pub(crate) mod native; +pub(crate) mod origin; +pub(crate) mod platform; +pub(crate) use capture::{CargoCapture, CompositionView, RetainedCompositionExport}; + +const MAX_PACKAGES: usize = 16_384; +const MAX_MESSAGES: usize = 65_536; + +fn composition_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Composition).attach(detail.to_string()) +} + +fn text<'a>(value: &'a Value, label: &str) -> Result<&'a str, Report> { + value + .as_str() + .filter(|text| { + !text.is_empty() && text.len() <= 8192 && !text.chars().any(char::is_control) + }) + .ok_or_else(|| composition_error(format!("{label} is not bounded plain text"))) +} + +fn array<'a>(value: &'a Value, label: &str) -> Result<&'a [Value], Report> { + value + .as_array() + .map(Vec::as_slice) + .ok_or_else(|| composition_error(format!("{label} is not an array"))) +} + +fn strings(value: &Value, label: &str, unique: bool) -> Result, Report> { + let values = array(value, label)? + .iter() + .map(|value| text(value, label).map(str::to_owned)) + .collect::, _>>()?; + if unique && values.iter().collect::>().len() != values.len() { + return Err(composition_error(format!("{label} contains duplicates"))); + } + Ok(values) +} + +fn normal_path(path: &Path) -> Result<(), Report> { + if !path.is_absolute() + || path == Path::new("/") + || path.components().collect::().as_os_str() != path.as_os_str() + || path + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(composition_error( + "observation path is not absolute and normal", + )); + } + Ok(()) +} + +fn path(value: &Value, label: &str) -> Result> { + let path = PathBuf::from(text(value, label)?); + normal_path(&path)?; + Ok(path) +} + +/// Explicit-target output layout of one already selected build. +pub(crate) struct CargoLayout { + target: ReleaseTarget, + target_dir: PathBuf, + checkout: PathBuf, +} + +impl CargoLayout { + /// Checks the namespace paths used to classify compiler outputs. + /// + /// # Errors + /// + /// Rejects relative, root-only, overlapping or non-normal directories. + pub(crate) fn new( + target: ReleaseTarget, + target_dir: PathBuf, + checkout: PathBuf, + ) -> Result> { + normal_path(&target_dir)?; + normal_path(&checkout)?; + if target_dir.starts_with(&checkout) || checkout.starts_with(&target_dir) { + return Err(composition_error("composition roots overlap")); + } + Ok(Self { + target, + target_dir, + checkout, + }) + } + + fn context(&self, path: &Path) -> Result> { + normal_path(path)?; + for (context, root) in [ + ( + UnitContext::Target, + self.target_dir.join(self.target.triple()).join("release"), + ), + (UnitContext::Host, self.target_dir.join("release")), + ] { + if path + .strip_prefix(&root) + .is_ok_and(|relative| !relative.as_os_str().is_empty()) + { + return Ok(context); + } + } + Err(composition_error( + "compiler output is outside both selected release layouts", + )) + } +} + +/// The host or explicit target context observed in Cargo's output paths. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum UnitContext { + Host, + Target, +} + +/// A package observed in actual compilation, identified by its exact Cargo ID. +#[derive(Debug, Serialize)] +pub(crate) struct RustPackage { + id: String, + name: String, + version: String, + source: Option, + manifest_path: PathBuf, + license: Option, +} + +/// One compiler observation, including its actual features and output context. +#[derive(Debug, Serialize)] +pub(crate) struct RustUnit { + package_id: String, + target_name: String, + target_kind: Vec, + crate_types: Vec, + context: UnitContext, + features: Vec, + profile: Value, + filenames: Vec, + executable: Option, +} + +/// A build-script execution observation, without invented per-unit feature joins. +#[derive(Debug, Serialize)] +pub(crate) struct BuildScriptObservation { + package_id: String, + context: UnitContext, + out_dir: PathBuf, + linked_libs: Vec, + linked_paths: Vec, + cfgs: Vec, + environment: Vec<(String, String)>, +} + +/// Conservative observed membership; not a verified material-origin inventory. +#[derive(Debug, Serialize)] +pub(crate) struct CargoObservations { + packages: Vec, + units: Vec, + build_scripts: Vec, +} + +impl CargoObservations { + /// Returns packages actually observed in compiler messages. + pub(crate) fn packages(&self) -> &[RustPackage] { + &self.packages + } + + /// Returns actual units without promoting metadata feature unions. + pub(crate) fn units(&self) -> &[RustUnit] { + &self.units + } + + /// Returns observed build-script executions. + pub(crate) fn build_scripts(&self) -> &[BuildScriptObservation] { + &self.build_scripts + } +} + +impl RustPackage { + /// Returns the exact opaque Cargo package identity. + pub(crate) fn id(&self) -> &str { + &self.id + } + + /// Returns the checked package name. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Returns the checked package version. + pub(crate) fn version(&self) -> &str { + &self.version + } + + /// Returns the metadata license declaration, without a license conclusion. + pub(crate) fn license(&self) -> Option<&str> { + self.license.as_deref() + } +} + +impl RustUnit { + /// Returns the exact observed package identity. + pub(crate) fn package_id(&self) -> &str { + &self.package_id + } + + /// Returns the compiler target name. + pub(crate) fn target_name(&self) -> &str { + &self.target_name + } + + /// Returns compiler target kinds, including host proc macros and build scripts. + pub(crate) fn target_kind(&self) -> &[String] { + &self.target_kind + } + + /// Returns the observed crate types. + pub(crate) fn crate_types(&self) -> &[String] { + &self.crate_types + } + + /// Returns the observed host or selected target output context. + pub(crate) fn context(&self) -> UnitContext { + self.context + } + + /// Returns effective features from this compiler message only. + pub(crate) fn features(&self) -> &[String] { + &self.features + } +} + +fn package(value: &Value) -> Result> { + let optional = |key: &str| match value.get(key) { + Some(Value::Null) => Ok(None), + Some(value) => text(value, key).map(|text| Some(text.to_owned())), + None => Err(composition_error(format!("package has no {key}"))), + }; + Ok(RustPackage { + id: text(&value["id"], "package ID")?.to_owned(), + name: text(&value["name"], "package name")?.to_owned(), + version: text(&value["version"], "package version")?.to_owned(), + source: optional("source")?, + manifest_path: path(&value["manifest_path"], "package manifest")?, + license: optional("license")?, + }) +} + +fn unit( + message: &Value, + package: &Value, + layout: &CargoLayout, +) -> Result> { + if message["fresh"].as_bool() != Some(false) + || message["profile"]["test"].as_bool() != Some(false) + { + return Err(composition_error( + "compiler unit is cached or is not a non-test build", + )); + } + if path(&message["manifest_path"], "unit manifest")? + != path(&package["manifest_path"], "package manifest")? + { + return Err(composition_error( + "compiler unit manifest differs from its package", + )); + } + let target = &message["target"]; + let target_name = text(&target["name"], "target name")?.to_owned(); + let target_kind = strings(&target["kind"], "target kinds", true)?; + let crate_types = strings(&target["crate_types"], "crate types", true)?; + if target_kind.is_empty() || crate_types.is_empty() { + return Err(composition_error( + "compiler target has no kind or crate type", + )); + } + let matching = array(&package["targets"], "metadata targets")? + .iter() + .filter(|candidate| { + ["name", "kind", "crate_types", "src_path"] + .into_iter() + .all(|field| candidate[field] == target[field]) + }) + .count(); + path(&target["src_path"], "target source")?; + if matching != 1 { + return Err(composition_error( + "compiler target has no unique metadata target", + )); + } + let filenames = array(&message["filenames"], "compiler filenames")? + .iter() + .map(|value| path(value, "compiler filename")) + .collect::, _>>()?; + let first = filenames + .first() + .ok_or_else(|| composition_error("compiler unit has no output"))?; + let context = layout.context(first)?; + for filename in &filenames { + if layout.context(filename)? != context { + return Err(composition_error( + "one compiler unit spans host and target layouts", + )); + } + } + if target_kind + .iter() + .any(|kind| matches!(kind.as_str(), "custom-build" | "proc-macro")) + && context != UnitContext::Host + { + return Err(composition_error( + "build script or proc macro is not a host unit", + )); + } + let executable = match &message["executable"] { + Value::Null => None, + value => { + let executable = path(value, "unit executable")?; + if layout.context(&executable)? != context || !filenames.contains(&executable) { + return Err(composition_error("executable is outside its unit outputs")); + } + Some(executable) + } + }; + let mut features = strings(&message["features"], "effective features", true)?; + features.sort(); + Ok(RustUnit { + package_id: text(&message["package_id"], "unit package ID")?.to_owned(), + target_name, + target_kind, + crate_types, + context, + features, + profile: message["profile"].clone(), + filenames, + executable, + }) +} + +fn build_script( + message: &Value, + layout: &CargoLayout, +) -> Result> { + let out_dir = path(&message["out_dir"], "build script OUT_DIR")?; + let context = layout.context(&out_dir)?; + let environment = array(&message["env"], "build script environment")? + .iter() + .map(|entry| { + let fields = array(entry, "build script environment pair")?; + let [name, value] = fields else { + return Err(composition_error("environment entry is not one pair")); + }; + let name = text(name, "build script environment name")?.to_owned(); + let value = value + .as_str() + .filter(|value| value.len() <= 65_536 && !value.contains('\0')) + .ok_or_else(|| composition_error("build script environment value is invalid"))? + .to_owned(); + Ok((name, value)) + }) + .collect::, Report>>()?; + Ok(BuildScriptObservation { + package_id: text(&message["package_id"], "build script package ID")?.to_owned(), + context, + out_dir, + linked_libs: strings(&message["linked_libs"], "linked libraries", false)?, + linked_paths: strings(&message["linked_paths"], "link search paths", false)?, + cfgs: strings(&message["cfgs"], "build script cfg", false)?, + environment, + }) +} + +/// Joins strict build observations with exact metadata package/target identities. +/// +/// Only compiler messages supply unit features and contributor membership. +/// Metadata graph unions and uncompiled packages never create units. The +/// result describes observations, not verified native/material coverage. +/// +/// # Errors +/// +/// Rejects malformed/bounded-input violations, ambiguous joins, cached/test +/// units, invalid output contexts, duplicate units or unobserved build scripts. +pub(crate) fn observe_cargo( + metadata: &[u8], + stdout: &[u8], + layout: &CargoLayout, +) -> Result> { + let meta = parse_json(metadata, Role::CargoObservation) + .map_err(|error| error.change_context(ReleaseError::Composition))?; + if meta["version"].as_u64() != Some(1) + || path(&meta["workspace_root"], "metadata workspace")? != layout.checkout + || path(&meta["target_directory"], "metadata target directory")? != layout.target_dir + { + return Err(composition_error( + "metadata version or build layout differs from selection", + )); + } + let records = array(&meta["packages"], "metadata packages")?; + if records.len() > MAX_PACKAGES { + return Err(composition_error("too many metadata packages")); + } + let mut packages = BTreeMap::new(); + for record in records { + let id = text(&record["id"], "metadata package ID")?; + if packages.insert(id, record).is_some() { + return Err(composition_error("duplicate metadata package ID")); + } + } + let messages = observed_cargo_messages(stdout) + .map_err(|error| error.change_context(ReleaseError::Composition))?; + if messages.len() > MAX_MESSAGES { + return Err(composition_error("too many Cargo messages")); + } + let mut units = BTreeMap::new(); + let mut scripts = Vec::new(); + let mut observed_packages = BTreeSet::new(); + for message in &messages { + match message["reason"].as_str() { + Some("compiler-artifact") => { + let id = text(&message["package_id"], "unit package ID")?; + let package = packages.get(id).ok_or_else(|| { + composition_error(format!( + "compiled package {id:?} is absent from metadata catalog ({} packages)", + packages.len() + )) + })?; + let unit = unit(message, package, layout)?; + let key = serde_json::to_vec(&json!([ + unit.package_id, + unit.target_name, + unit.target_kind, + unit.crate_types, + unit.context, + unit.features, + unit.profile + ])) + .map_err(composition_error)?; + if units.insert(key, unit).is_some() { + return Err(composition_error("duplicate compiler unit identity")); + } + observed_packages.insert(id); + } + Some("build-script-executed") => scripts.push(build_script(message, layout)?), + Some("compiler-message" | "build-finished") => {} + _ => return Err(composition_error("unknown Cargo observation reason")), + } + } + let mut script_ids = BTreeSet::new(); + for script in &scripts { + if !units.values().any(|unit| { + unit.package_id == script.package_id + && unit.context == UnitContext::Host + && unit.target_kind.iter().any(|kind| kind == "custom-build") + }) || !script_ids.insert((&script.package_id, &script.out_dir)) + { + return Err(composition_error( + "build script execution has no unique observed compilation", + )); + } + } + scripts.sort_by(|a, b| (&a.package_id, &a.out_dir).cmp(&(&b.package_id, &b.out_dir))); + let packages = observed_packages + .into_iter() + .map(|id| package(packages[id])) + .collect::, _>>()?; + Ok(CargoObservations { + packages, + units: units.into_values().collect(), + build_scripts: scripts, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/composition/capture.rs b/crates/trusted-server-release/src/composition/capture.rs new file mode 100644 index 000000000..3c6e077b0 --- /dev/null +++ b/crates/trusted-server-release/src/composition/capture.rs @@ -0,0 +1,376 @@ +//! Retained raw observations and their normalized conservative unit inventory. + +use std::fs; +use std::io::Write as _; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::Path; + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::graph::{self, GraphEvidence}; +use super::javascript::JsMembership; +use super::native::NativeInventory; +use super::origin::VerifiedPackageOrigins; +use super::platform::PlatformEvidence; +use super::{CargoLayout, CargoObservations, composition_error, observe_cargo}; +use crate::documents::{hash_bounded_with_check, lower_hex}; +use crate::error::ReleaseError; + +const LIMIT: u64 = 16 * 1024 * 1024; +const METADATA: &str = "cargo-metadata.json"; +const TREE: &str = "cargo-tree.txt"; +const NORMALIZED: &str = "cargo-observations.json"; + +/// Raw Cargo observations bound to their checked unit normalization. +/// +/// This records observations only; verified material/native coverage remains +/// a separate requirement before a build composition can be attested. +pub(crate) struct CargoCapture { + metadata: Vec, + tree: Vec, + normalized: Vec, + digests: [String; 3], + observations: CargoObservations, + query_plan_digests: [String; 2], + receipt: Option, +} + +#[derive(serde::Serialize)] +struct CompositionReceipt { + graph: GraphEvidence, + package_origins: VerifiedPackageOrigins, + native: NativeInventory, + javascript: JsMembership, + platform: PlatformEvidence, +} + +/// Borrowed sealed observations after their retained bytes have been rechecked. +/// +/// This grants read access only. Potential graph membership and incomplete +/// native, tool and platform coverage retain their existing meanings. +pub(crate) struct CompositionView<'a> { + observations: &'a CargoObservations, + receipt: &'a CompositionReceipt, + receipt_sha256: &'a str, +} + +impl CompositionView<'_> { + /// Returns the exact retained normalized receipt identity without reserialization. + pub(crate) fn receipt_sha256(&self) -> &str { + self.receipt_sha256 + } + + /// Returns actual compiler observations, including unit context and features. + pub(crate) fn observations(&self) -> &CargoObservations { + self.observations + } + + /// Returns potential graph evidence separately from compiler observations. + pub(crate) fn graph(&self) -> &GraphEvidence { + &self.receipt.graph + } + + /// Returns exact observed package IDs joined to verified originals. + pub(crate) fn package_origins(&self) -> &VerifiedPackageOrigins { + &self.receipt.package_origins + } + + /// Returns candidate native measurements with their existing coverage gaps. + pub(crate) fn native(&self) -> &NativeInventory { + &self.receipt.native + } + + /// Returns final-byte JavaScript module observations. + pub(crate) fn javascript(&self) -> &JsMembership { + &self.receipt.javascript + } + + /// Returns candidate platform measurements without acquisition authority. + pub(crate) fn platform(&self) -> &PlatformEvidence { + &self.receipt.platform + } +} + +/// Exact retained composition bytes, without a claim of complete coverage. +/// +/// Only the owning capture can construct this diagnostic export. It retains +/// sealed observations after working source and native output roots are removed. +#[derive(Debug)] +pub(crate) struct RetainedCompositionExport { + bytes: Vec, + sha256: String, + summary: Value, +} + +impl RetainedCompositionExport { + /// Returns the original normalized receipt bytes without reserialization. + pub(crate) fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// Returns the SHA-256 of these exact retained bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Returns observation counts and explicit incomplete coverage diagnostics. + pub(crate) fn summary(&self) -> &Value { + &self.summary + } +} + +impl core::fmt::Debug for CargoCapture { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("CargoCapture") + .field("digests", &self.digests) + .field("has_composition_receipt", &self.receipt.is_some()) + .finish_non_exhaustive() + } +} + +impl CargoCapture { + /// Borrows sealed typed evidence after rechecking all retained observations. + /// + /// # Errors + /// + /// Rejects changed retained bytes or observations without sealed receipts. + pub(crate) fn view( + &self, + directory: &Path, + ) -> Result, Report> { + self.rehash(directory)?; + let receipt = self.receipt.as_ref().ok_or_else(|| { + composition_error("composition view requires actual sealed component receipts") + })?; + Ok(CompositionView { + observations: &self.observations, + receipt, + receipt_sha256: &self.digests[2], + }) + } + + /// Normalizes a successful build and its same-boundary query output. + /// + /// # Errors + /// + /// Rejects invalid unit/metadata joins, non-text or oversized tree output, + /// or normalization exceeding the internal observation bound. + pub(crate) fn new( + metadata: Vec, + tree: Vec, + stdout: &[u8], + layout: &CargoLayout, + query_plan_digests: [String; 2], + ) -> Result> { + if query_plan_digests + .iter() + .any(|digest| !lower_hex(digest, 64)) + { + return Err(composition_error( + "query plans have no exact SHA-256 identities", + )); + } + if tree.is_empty() + || tree.len() as u64 > LIMIT + || tree.contains(&0) + || std::str::from_utf8(&tree).is_err() + || !tree.ends_with(b"\n") + { + return Err(composition_error( + "Cargo tree output is not bounded newline-terminated text", + )); + } + let observations = observe_cargo(&metadata, stdout, layout)?; + let normalized = serde_json::to_vec(&json!({ + "metadata_plan_sha256": query_plan_digests[0], + "tree_plan_sha256": query_plan_digests[1], + "observations": observations, + })) + .map_err(composition_error)?; + if normalized.len() as u64 > LIMIT { + return Err(composition_error( + "normalized observations exceed their bound", + )); + } + let digests = + [&metadata, &tree, &normalized].map(|bytes| hex::encode(Sha256::digest(bytes))); + Ok(Self { + metadata, + tree, + normalized, + digests, + observations, + query_plan_digests, + receipt: None, + }) + } + + /// The observed units whose inputs must be joined before sealing evidence. + pub(crate) fn observations(&self) -> &CargoObservations { + &self.observations + } + + /// Seals checked component receipts while their working inputs still exist. + /// + /// Native sources and archives are rehashed here. Later handoffs verify the + /// retained receipt bytes, allowing completed working roots to be cleaned. + /// Full tool/platform coverage and qualification remain separate gates. + /// + /// # Errors + /// + /// Rejects changed native inputs or oversized serialized evidence. + pub(crate) fn with_evidence( + mut self, + selected_package_id: &str, + package_origins: VerifiedPackageOrigins, + native: NativeInventory, + javascript: JsMembership, + platform: PlatformEvidence, + ) -> Result> { + let graph = graph::reconcile( + &self.metadata, + &self.tree, + &self.observations, + selected_package_id, + )?; + native.rehash(&|| Ok(()))?; + let receipt = CompositionReceipt { + graph, + package_origins, + native, + javascript, + platform, + }; + let normalized = serde_json::to_vec(&json!({ + "metadata_plan_sha256": self.query_plan_digests[0], + "tree_plan_sha256": self.query_plan_digests[1], + "observations": self.observations, + "composition": receipt, + })) + .map_err(composition_error)?; + if normalized.len() as u64 > LIMIT { + return Err(composition_error( + "normalized composition exceeds its bound", + )); + } + self.digests[2] = hex::encode(Sha256::digest(&normalized)); + self.normalized = normalized; + self.receipt = Some(receipt); + Ok(self) + } + + /// Returns the exact package identity of the binary accepted by the recipe. + /// + /// # Errors + /// + /// Rejects missing or ambiguous binary membership and mismatched manifests. + pub(crate) fn require_binary( + &self, + manifest: &Path, + bin: &str, + executable: &Path, + ) -> Result> { + let matching = self + .observations + .units + .iter() + .filter(|unit| { + unit.target_name == bin + && unit.target_kind == ["bin"] + && unit.context == super::UnitContext::Target + && unit.executable.as_deref() == Some(executable) + && self.observations.packages.iter().any(|package| { + package.id == unit.package_id && package.manifest_path == manifest + }) + }) + .collect::>(); + if matching.len() != 1 { + return Err(composition_error( + "composition does not name the exact accepted binary", + )); + } + Ok(matching[0].package_id.clone()) + } + + /// Retains every raw/normalized observation as a new private file. + /// + /// # Errors + /// + /// Returns an error on existing files or failed writes/synchronization. + pub(crate) fn retain(&self, directory: &Path) -> Result<(), Report> { + for (name, bytes) in [ + (METADATA, &self.metadata), + (TREE, &self.tree), + (NORMALIZED, &self.normalized), + ] { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(directory.join(name)) + .map_err(composition_error)?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(composition_error)?; + } + Ok(()) + } + + /// Verifies retained observations immediately before a later handoff. + /// + /// # Errors + /// + /// Rejects changed, aliased, linked, missing or oversized retained observations. + pub(crate) fn rehash(&self, directory: &Path) -> Result<(), Report> { + for (name, expected) in [METADATA, TREE, NORMALIZED].into_iter().zip(&self.digests) { + let measured = hash_bounded_with_check(&directory.join(name), LIMIT, &|| Ok(())) + .map_err(|error| error.change_context(ReleaseError::Composition))?; + if &measured.sha256 != expected { + return Err(composition_error(format!("retained {name} changed"))); + } + } + Ok(()) + } + + /// Exports exact sealed composition bytes after checking every retained input. + /// + /// # Errors + /// + /// Rejects changed retained files or observations without actual graph, + /// origin, native, JavaScript and platform receipts. Existing coverage gaps + /// remain explicit; this cannot construct complete or qualified evidence. + pub(crate) fn export( + &self, + directory: &Path, + ) -> Result> { + self.rehash(directory)?; + if self.receipt.is_none() { + return Err(composition_error( + "composition export requires actual sealed component receipts", + )); + } + Ok(RetainedCompositionExport { + bytes: self.normalized.clone(), + sha256: self.digests[2].clone(), + summary: self.summary(), + }) + } + + /// Describes internal observations without claiming complete native/material coverage. + pub(crate) fn summary(&self) -> Value { + json!({ + "metadata_sha256": self.digests[0], "tree_sha256": self.digests[1], + "metadata_plan_sha256": self.query_plan_digests[0], + "tree_plan_sha256": self.query_plan_digests[1], + "normalized_sha256": self.digests[2], + "observed_packages": self.observations.packages.len(), + "observed_units": self.observations.units.len(), + "build_script_executions": self.observations.build_scripts.len(), + "composition_receipts_retained": self.receipt.is_some(), + "material_native_coverage_complete": false, + }) + } +} diff --git a/crates/trusted-server-release/src/composition/external.rs b/crates/trusted-server-release/src/composition/external.rs new file mode 100644 index 000000000..f96def1e6 --- /dev/null +++ b/crates/trusted-server-release/src/composition/external.rs @@ -0,0 +1,113 @@ +//! Logical runtime requirements observed in inspected final executable bytes. + +use serde::{Serialize, Serializer}; + +use crate::inspect::{ElfFacts, MachoCpu, MachoFacts, VersionRequirement, WasmFacts}; +use crate::policy::ReleaseTarget; + +/// Observed external requirements, without installed-runtime provenance claims. +#[derive(Debug, Serialize)] +pub(crate) struct ExternalRequirements { + #[serde(serialize_with = "serialize_target")] + target: ReleaseTarget, + artifact_sha256: String, + runtime: Runtime, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "format", rename_all = "snake_case")] +pub(crate) enum Runtime { + Elf { + interpreter: String, + needed: Vec, + version_requirements: Vec, + max_glibc: String, + }, + Macho { + loader: String, + cpu: String, + dylibs: Vec, + minimum_os: String, + sdk: String, + }, + Wasm { + import_modules: Vec, + }, +} + +fn serialize_target( + target: &ReleaseTarget, + serializer: S, +) -> Result { + serializer.serialize_str(target.triple()) +} + +impl ExternalRequirements { + /// Borrows exact logical requirements without installed-runtime identity. + pub(crate) fn runtime(&self) -> &Runtime { + &self.runtime + } + /// Returns the SDK recorded in a Mach-O executable, when present. + pub(crate) fn macho_sdk(&self) -> Option<&str> { + match &self.runtime { + Runtime::Macho { sdk, .. } => Some(sdk), + Runtime::Elf { .. } | Runtime::Wasm { .. } => None, + } + } + + /// Retains logical requirements from accepted GNU/Linux executable facts. + pub(crate) fn from_elf(facts: &ElfFacts) -> Self { + Self { + target: ReleaseTarget::X86_64UnknownLinuxGnu, + artifact_sha256: facts.sha256().to_owned(), + runtime: Runtime::Elf { + interpreter: facts.interpreter().to_owned(), + needed: facts.needed().to_vec(), + version_requirements: facts.version_requirements().to_vec(), + max_glibc: facts.max_glibc().to_string(), + }, + } + } + + /// Retains logical requirements from accepted macOS executable facts. + pub(crate) fn from_macho(facts: &MachoFacts) -> Self { + Self { + target: match facts.cpu() { + MachoCpu::Arm64 => ReleaseTarget::Aarch64AppleDarwin, + MachoCpu::X86_64 => ReleaseTarget::X86_64AppleDarwin, + }, + artifact_sha256: facts.sha256().to_owned(), + runtime: Runtime::Macho { + loader: facts.loader().to_owned(), + cpu: facts.cpu().to_string(), + dylibs: facts.dylibs().to_vec(), + minimum_os: facts.minos().to_string(), + sdk: facts.sdk().to_string(), + }, + } + } + + /// Retains logical host imports from accepted Fastly core Wasm facts. + pub(crate) fn from_wasm(facts: &WasmFacts) -> Self { + Self { + target: ReleaseTarget::Wasm32Wasip1, + artifact_sha256: facts.sha256().to_owned(), + runtime: Runtime::Wasm { + import_modules: facts.import_modules().to_vec(), + }, + } + } + + /// Target derived from the accepted executable format and CPU. + pub(crate) fn target(&self) -> ReleaseTarget { + self.target + } + + /// SHA-256 computed by the inspector over the accepted executable bytes. + pub(crate) fn artifact_sha256(&self) -> &str { + &self.artifact_sha256 + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/composition/external/tests.rs b/crates/trusted-server-release/src/composition/external/tests.rs new file mode 100644 index 000000000..15797928f --- /dev/null +++ b/crates/trusted-server-release/src/composition/external/tests.rs @@ -0,0 +1,170 @@ +use object::macho as macho_format; +use serde_json::{Value, json}; + +use super::*; +use crate::inspect::tests::builders::{ElfSpec, MachoSpec, elf, macho, wasm_module}; +use crate::inspect::{ElfPolicy, MachoPolicy, inspect_elf, inspect_macho, inspect_wasm}; + +fn encoded(requirements: &ExternalRequirements) -> Value { + serde_json::to_value(requirements).expect("should encode typed runtime requirements") +} + +#[test] +fn elf_requirements_retain_only_inspected_loader_libraries_and_versions() { + let mut spec = ElfSpec::conforming(); + spec.needed.retain(|library| *library != "libm.so.6"); + let facts = inspect_elf(&elf(&spec), &ElfPolicy::linux_x86_64_gnu()) + .expect("should accept ELF fixture"); + let requirements = ExternalRequirements::from_elf(&facts); + assert_eq!( + requirements.target(), + ReleaseTarget::X86_64UnknownLinuxGnu, + "should derive supported GNU target" + ); + assert_eq!( + requirements.artifact_sha256(), + facts.sha256(), + "should bind inspector artifact digest" + ); + let value = encoded(&requirements); + assert_eq!( + value["runtime"]["interpreter"], + facts.interpreter(), + "should retain actual ELF interpreter" + ); + assert_eq!( + value["runtime"]["needed"], + json!(["libgcc_s.so.1", "libc.so.6"]), + "should not add allowed but absent libraries" + ); + assert_eq!( + value["runtime"]["max_glibc"], "2.34", + "should retain observed GLIBC maximum" + ); + assert_eq!( + value["runtime"]["version_requirements"] + .as_array() + .expect("should hold versions") + .len(), + 5, + "should retain every library/version pair" + ); + assert!( + value["runtime"]["version_requirements"] + .as_array() + .expect("should hold versions") + .contains(&json!({"file":"libc.so.6","name":"GLIBC_2.34"})), + "should bind symbol version to its observed library" + ); +} + +#[test] +fn macho_requirements_retain_actual_loader_cpu_and_deployment_versions() { + let facts = inspect_macho( + &macho(&MachoSpec::conforming()), + &MachoPolicy::macos(MachoCpu::Arm64), + ) + .expect("should accept Mach-O fixture"); + let requirements = ExternalRequirements::from_macho(&facts); + assert_eq!( + requirements.target(), + ReleaseTarget::Aarch64AppleDarwin, + "should derive Apple CPU target" + ); + assert_eq!( + requirements.artifact_sha256(), + facts.sha256(), + "should bind inspector artifact digest" + ); + let value = encoded(&requirements); + assert_eq!( + value["runtime"]["loader"], "/usr/lib/dyld", + "should retain validated observed loader" + ); + assert_eq!( + value["runtime"]["dylibs"], + json!(facts.dylibs()), + "should retain only observed dylibs" + ); + assert_eq!( + value["runtime"]["minimum_os"], "13.0.0", + "should retain observed minimum OS" + ); + assert_eq!( + value["runtime"]["sdk"], "15.5.0", + "should retain observed SDK" + ); + assert_eq!( + value["runtime"]["cpu"], "arm64", + "should retain observed CPU" + ); +} + +#[test] +fn wasm_requirements_retain_only_observed_modules_and_inspector_digest() { + for imports in [ + vec![], + vec![ + ("wasi_snapshot_preview1", "fd_write"), + ("fastly_http_req", "send"), + ], + ] { + let facts = inspect_wasm(&wasm_module(&imports)).expect("should accept core Wasm fixture"); + let requirements = ExternalRequirements::from_wasm(&facts); + assert_eq!( + requirements.target(), + ReleaseTarget::Wasm32Wasip1, + "should derive only supported Wasm release target" + ); + assert_eq!( + requirements.artifact_sha256(), + facts.sha256(), + "should bind inspector artifact digest" + ); + let value = encoded(&requirements); + assert_eq!( + value["runtime"]["import_modules"], + json!(facts.import_modules()), + "should not add assumed host modules" + ); + assert!( + value["runtime"].get("needed").is_none(), + "should not claim native dynamic libraries for Wasm" + ); + } +} + +#[test] +fn x86_64_macho_facts_cannot_be_relabelled_as_arm64() { + let mut spec = MachoSpec::conforming(); + spec.cputype = macho_format::CPU_TYPE_X86_64; + spec.cpusubtype = macho_format::CPU_SUBTYPE_X86_64_ALL; + let facts = inspect_macho(&macho(&spec), &MachoPolicy::macos(MachoCpu::X86_64)) + .expect("should accept x86_64 Mach-O fixture"); + let requirements = ExternalRequirements::from_macho(&facts); + assert_eq!( + requirements.target(), + ReleaseTarget::X86_64AppleDarwin, + "should retain supported CPU mapping" + ); + assert_eq!( + encoded(&requirements)["runtime"]["cpu"], + "x86_64", + "should retain inspected CPU" + ); +} + +#[test] +fn changed_accepted_bytes_produce_different_artifact_bindings() { + let mut spec = ElfSpec::conforming(); + let original = inspect_elf(&elf(&spec), &ElfPolicy::linux_x86_64_gnu()) + .expect("should accept original ELF"); + spec.payload = b"different fictional payload"; + let changed = inspect_elf(&elf(&spec), &ElfPolicy::linux_x86_64_gnu()) + .expect("should accept changed ELF"); + assert_ne!( + ExternalRequirements::from_elf(&original).artifact_sha256(), + ExternalRequirements::from_elf(&changed).artifact_sha256(), + "should bind final bytes rather than only equal loader requirements" + ); +} diff --git a/crates/trusted-server-release/src/composition/graph.rs b/crates/trusted-server-release/src/composition/graph.rs new file mode 100644 index 000000000..6f8b11c0b --- /dev/null +++ b/crates/trusted-server-release/src/composition/graph.rs @@ -0,0 +1,413 @@ +//! Conservative package graph checks; compiler observations retain unit authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use serde::Serialize; +use serde_json::Value; + +use super::{ + CargoObservations, MAX_PACKAGES, UnitContext, array, composition_error, package, strings, text, +}; +use crate::documents::{Role, parse_json}; +use crate::error::ReleaseError; + +// A non-deduplicated tree repeats package paths far more often than compilation +// emits units. Keep this independent of the compiler-message count limit. +const MAX_TREE_OCCURRENCES: usize = 1_048_576; + +/// Reconciled package membership, without claiming a compiler unit dependency DAG. +#[derive(Debug, Serialize)] +pub(crate) struct GraphEvidence { + selected_package_id: String, + tree_package_ids: Vec, + observed_package_ids: Vec, + edges: Vec, +} + +/// A potential metadata dependency edge, without compiled-unit authority. +#[derive(Debug, Serialize)] +pub(crate) struct GraphEdge { + parent: String, + dependency: String, + kinds: Vec, +} + +impl GraphEvidence { + /// Returns the exact selected binary's Cargo package identity. + pub(crate) fn selected_package_id(&self) -> &str { + &self.selected_package_id + } + + /// Returns potential package membership from the reconciled Cargo tree. + pub(crate) fn tree_package_ids(&self) -> &[String] { + &self.tree_package_ids + } + + /// Returns package membership proved by actual compiler messages. + pub(crate) fn observed_package_ids(&self) -> &[String] { + &self.observed_package_ids + } + + /// Returns potential metadata edges, without claiming a compiled unit DAG. + pub(crate) fn potential_edges(&self) -> &[GraphEdge] { + &self.edges + } +} + +impl GraphEdge { + /// Returns the exact potential parent package identity. + pub(crate) fn parent(&self) -> &str { + &self.parent + } + + /// Returns the exact potential dependency package identity. + pub(crate) fn dependency(&self) -> &str { + &self.dependency + } + + /// Returns declared dependency kinds without inferring embedded membership. + pub(crate) fn kinds(&self) -> &[String] { + &self.kinds + } +} + +type Edges<'a> = BTreeMap<(&'a str, &'a str), BTreeSet<&'a str>>; +type IdentityIndex<'a> = BTreeMap<(&'a str, &'a str), Vec<&'a str>>; + +struct ResolvedGraph<'a> { + nodes: BTreeSet<&'a str>, + edges: Edges<'a>, +} + +fn metadata_edges<'a>( + metadata: &'a Value, + catalog: &BTreeMap<&str, &Value>, +) -> Result, Report> { + let nodes = array(&metadata["resolve"]["nodes"], "resolve nodes")?; + if nodes.len() > MAX_PACKAGES { + return Err(composition_error("too many resolve nodes")); + } + let mut node_ids = BTreeSet::new(); + let mut edges = BTreeMap::new(); + for node in nodes { + let id = text(&node["id"], "resolve node ID")?; + if !catalog.contains_key(id) || !node_ids.insert(id) { + return Err(composition_error("unknown or duplicate resolve node")); + } + let dependencies = strings(&node["dependencies"], "resolve dependencies", true)? + .into_iter() + .collect::>(); + let mut endpoints = BTreeSet::new(); + for dep in array(&node["deps"], "resolve dependency edges")? { + text(&dep["name"], "resolve dependency name")?; + let endpoint = text(&dep["pkg"], "resolve dependency package")?; + if !catalog.contains_key(endpoint) { + return Err(composition_error( + "resolve edge has unknown package endpoint", + )); + } + endpoints.insert(endpoint.to_owned()); + let kinds = array(&dep["dep_kinds"], "dependency kinds")?; + if kinds.is_empty() { + return Err(composition_error("resolve dependency has no kinds")); + } + let edge = edges.entry((id, endpoint)).or_insert_with(BTreeSet::new); + for kind in kinds { + match kind.get("target") { + Some(Value::Null) => {} + Some(target) => { + text(target, "dependency target condition")?; + } + None => { + return Err(composition_error("dependency kind lacks target condition")); + } + } + let kind = match kind.get("kind") { + Some(Value::Null) => "normal", + Some(Value::String(kind)) if matches!(kind.as_str(), "build" | "dev") => { + kind.as_str() + } + _ => return Err(composition_error("unknown dependency kind")), + }; + edge.insert(kind); + } + } + if endpoints != dependencies { + return Err(composition_error( + "resolve dependency endpoint lists disagree", + )); + } + } + if edges + .keys() + .any(|(_, dependency)| !node_ids.contains(dependency)) + { + return Err(composition_error("resolve dependency has no node")); + } + Ok(ResolvedGraph { + nodes: node_ids, + edges, + }) +} + +fn tree_identity<'a>( + display: &str, + catalog: &BTreeMap<&'a str, &Value>, + identities: &IdentityIndex<'a>, +) -> Result<&'a str, Report> { + if display.ends_with(" (*)") { + return Err(composition_error("tree contains a deduplicated subtree")); + } + let (name, rest) = display + .split_once(" v") + .ok_or_else(|| composition_error("tree package lacks name/version"))?; + let (version, presentation) = rest.split_once(' ').unwrap_or((rest, "")); + let candidates = identities + .get(&(name, version)) + .map(Vec::as_slice) + .unwrap_or_default(); + let [id] = candidates else { + return Err(composition_error( + "tree package has no unique catalog identity", + )); + }; + let candidate = catalog[id]; + if candidate["source"].is_null() { + let manifest = package(candidate)?.manifest_path; + let parent = manifest + .parent() + .ok_or_else(|| composition_error("tree path package has no manifest parent"))?; + if presentation != format!("({})", parent.display()) { + return Err(composition_error( + "tree path presentation differs from metadata manifest", + )); + } + } else if !(presentation.is_empty() + || presentation.starts_with('(') && presentation.ends_with(')')) + { + return Err(composition_error("tree source presentation is malformed")); + } + Ok(id) +} + +fn contexts(parent: &[UnitContext], kinds: &BTreeSet<&str>, proc_macro: bool) -> Vec { + let mut result = Vec::new(); + for context in parent { + for kind in kinds { + let context = if *kind == "build" || proc_macro { + UnitContext::Host + } else { + *context + }; + if *kind != "dev" && !result.contains(&context) { + result.push(context); + } + } + } + result +} + +/// Reconciles explicit-target tree edges with the catalog and actual compilation. +/// +/// # Errors +/// +/// Rejects ambiguous package joins, invalid tree/metadata edges, and observations +/// without selected normal/build graph reachability in their compiler context. +pub(crate) fn reconcile( + metadata: &[u8], + tree: &[u8], + observed: &CargoObservations, + selected_package_id: &str, +) -> Result> { + let metadata = parse_json(metadata, Role::CargoObservation) + .map_err(|error| error.change_context(ReleaseError::Composition))?; + if metadata["version"].as_u64() != Some(1) { + return Err(composition_error("unsupported metadata version")); + } + let packages = array(&metadata["packages"], "metadata packages")?; + if packages.len() > MAX_PACKAGES { + return Err(composition_error("too many metadata packages")); + } + let mut catalog = BTreeMap::new(); + let mut identities = IdentityIndex::new(); + for value in packages { + let id = text(&value["id"], "catalog package ID")?; + package(value)?; + if catalog.insert(id, value).is_some() { + return Err(composition_error("duplicate catalog package ID")); + } + identities + .entry(( + text(&value["name"], "catalog package name")?, + text(&value["version"], "catalog package version")?, + )) + .or_default() + .push(id); + } + let metadata_edges = metadata_edges(&metadata, &catalog)?; + if !catalog.contains_key(selected_package_id) { + return Err(composition_error("selected package is absent from catalog")); + } + if tree.is_empty() || tree.len() > 16 * 1024 * 1024 || !tree.ends_with(b"\n") { + return Err(composition_error( + "tree is not bounded newline-terminated text", + )); + } + let tree = std::str::from_utf8(tree).map_err(composition_error)?; + let mut stack: Vec<(&str, Vec)> = Vec::new(); + let mut reachable: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut selected_edges = BTreeMap::new(); + for (line_index, line) in tree.lines().enumerate() { + if line_index >= MAX_TREE_OCCURRENCES + || line.len() > 16_384 + || line.chars().any(char::is_control) + { + return Err(composition_error("tree line violates bounds")); + } + let prefix = line.bytes().take_while(u8::is_ascii_digit).count(); + let depth = line[..prefix].parse::().map_err(composition_error)?; + if depth > stack.len() || (line_index != 0 && depth == 0) || (line_index == 0 && depth != 0) + { + return Err(composition_error( + "tree depth does not describe one selected root", + )); + } + let id = tree_identity(&line[prefix..], &catalog, &identities)?; + if !metadata_edges.nodes.contains(id) { + return Err(composition_error("tree package has no resolve node")); + } + stack.truncate(depth); + if stack.iter().any(|(ancestor, _)| *ancestor == id) { + return Err(composition_error("tree dependency cycle")); + } + let unit_contexts = if let Some((parent, parent_contexts)) = stack.last() { + let kinds = metadata_edges + .edges + .get(&(*parent, id)) + .ok_or_else(|| composition_error("tree edge is absent from metadata resolve"))?; + let kinds = kinds + .iter() + .copied() + .filter(|kind| *kind != "dev") + .collect::>(); + if kinds.is_empty() { + return Err(composition_error("tree edge is dev-only")); + } + let proc_macro = array(&catalog[id]["targets"], "package targets")? + .iter() + .any(|target| { + target["kind"].as_array().is_some_and(|kinds| { + kinds.iter().any(|kind| kind.as_str() == Some("proc-macro")) + }) + }); + selected_edges.insert((*parent, id), kinds.clone()); + contexts(parent_contexts, &kinds, proc_macro) + } else { + if id != selected_package_id { + return Err(composition_error("tree root differs from selected package")); + } + vec![UnitContext::Target] + }; + let all = reachable.entry(id).or_default(); + for context in &unit_contexts { + if !all.contains(context) { + all.push(*context); + } + } + stack.push((id, unit_contexts)); + } + let mut observed_ids = BTreeSet::new(); + for package in &observed.packages { + if !reachable.contains_key(package.id.as_str()) || !observed_ids.insert(package.id.as_str()) + { + return Err(composition_error( + "compiled package lacks unique selected tree membership", + )); + } + } + let mut compiled_ids = BTreeSet::new(); + let mut custom_build_ids = BTreeSet::new(); + let mut consumer_contexts = BTreeSet::new(); + let mut binaries = 0; + for unit in &observed.units { + let contexts = reachable + .get(unit.package_id.as_str()) + .ok_or_else(|| composition_error("compiler unit is absent from selected tree"))?; + if !observed_ids.contains(unit.package_id.as_str()) { + return Err(composition_error("compiler unit has no observed package")); + } + let custom_build = unit.target_kind.iter().any(|kind| kind == "custom-build"); + if custom_build { + custom_build_ids.insert(unit.package_id.as_str()); + } else { + consumer_contexts.insert(( + unit.package_id.as_str(), + unit.context == UnitContext::Target, + )); + } + if (custom_build && unit.context != UnitContext::Host) + || (!custom_build && !contexts.contains(&unit.context)) + { + return Err(composition_error( + "compiler context is unreachable through normal/build graph", + )); + } + if unit.target_kind.iter().any(|kind| kind == "bin") { + binaries += 1; + if unit.package_id != selected_package_id || unit.context != UnitContext::Target { + return Err(composition_error( + "compiler binary is outside selected target package", + )); + } + } + compiled_ids.insert(unit.package_id.as_str()); + } + if binaries != 1 || compiled_ids != observed_ids { + return Err(composition_error( + "observed membership differs from compiled selected binary graph", + )); + } + let executed_ids: BTreeSet<_> = observed + .build_scripts + .iter() + .map(|script| script.package_id.as_str()) + .collect(); + if executed_ids != custom_build_ids { + return Err(composition_error( + "compiled build scripts differ from observed executions", + )); + } + let executed_contexts: BTreeSet<_> = observed + .build_scripts + .iter() + .map(|script| { + ( + script.package_id.as_str(), + script.context == UnitContext::Target, + ) + }) + .collect(); + consumer_contexts.retain(|(id, _)| custom_build_ids.contains(id)); + if executed_contexts != consumer_contexts { + return Err(composition_error( + "build-script execution contexts differ from observed consumers", + )); + } + Ok(GraphEvidence { + selected_package_id: selected_package_id.to_owned(), + tree_package_ids: reachable.keys().map(|id| (*id).to_owned()).collect(), + observed_package_ids: observed_ids.into_iter().map(str::to_owned).collect(), + edges: selected_edges + .into_iter() + .map(|((parent, dependency), kinds)| GraphEdge { + parent: parent.to_owned(), + dependency: dependency.to_owned(), + kinds: kinds.into_iter().map(str::to_owned).collect(), + }) + .collect(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/composition/graph/tests.rs b/crates/trusted-server-release/src/composition/graph/tests.rs new file mode 100644 index 000000000..48be302d8 --- /dev/null +++ b/crates/trusted-server-release/src/composition/graph/tests.rs @@ -0,0 +1,492 @@ +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use super::*; +use crate::composition::{CargoLayout, UnitContext, observe_cargo}; +use crate::policy::ReleaseTarget; + +const METADATA: &[u8] = + include_bytes!("../../../tests/fixtures/composition/cargo-1.95-cross-host-metadata.json"); +const TREE: &[u8] = include_bytes!("../../../tests/fixtures/composition/cargo-1.95-cross-tree.txt"); +const MESSAGES: &[u8] = + include_bytes!("../../../tests/fixtures/composition/cargo-1.95-cross-messages.jsonl"); +const ROOT: &str = "path+file:///build/source/app#example-app@0.1.0"; + +fn observed() -> CargoObservations { + let layout = CargoLayout::new( + ReleaseTarget::Wasm32Wasip1, + PathBuf::from("/build/target"), + PathBuf::from("/build/source"), + ) + .expect("should accept fixture layout"); + observe_cargo(METADATA, MESSAGES, &layout).expect("should observe actual compiler units") +} + +#[test] +fn compiled_build_script_requires_its_execution_observation() { + let mut messages = std::str::from_utf8(MESSAGES) + .expect("should read actual Cargo fixture") + .lines() + .filter(|line| { + let message: Value = serde_json::from_str(line).expect("should parse Cargo message"); + message["reason"] != "build-script-executed" + }) + .collect::>() + .join("\n"); + messages.push('\n'); + let layout = CargoLayout::new( + ReleaseTarget::Wasm32Wasip1, + PathBuf::from("/build/target"), + PathBuf::from("/build/source"), + ) + .expect("should accept fixture layout"); + let observed = observe_cargo(METADATA, messages.as_bytes(), &layout) + .expect("should parse the remaining compiler observations"); + assert!( + reconcile(METADATA, TREE, &observed, ROOT).is_err(), + "should not seal a graph that loses build-script cfg, environment and link evidence" + ); +} + +#[test] +fn build_script_execution_requires_a_matching_consumer_context() { + let messages = std::str::from_utf8(MESSAGES) + .expect("should read Cargo messages") + .replace( + "/build/target/wasm32-wasip1/release/build/example-app-f1024798a111146f/out", + "/build/target/release/build/example-app-f1024798a111146f/out", + ); + let layout = CargoLayout::new( + ReleaseTarget::Wasm32Wasip1, + PathBuf::from("/build/target"), + PathBuf::from("/build/source"), + ) + .expect("should accept fixture layout"); + let observed = observe_cargo(METADATA, messages.as_bytes(), &layout) + .expect("should parse host-context script execution"); + assert!( + reconcile(METADATA, TREE, &observed, ROOT).is_err(), + "should reject a host execution when only a target consumer was observed" + ); +} + +#[test] +fn dual_role_build_scripts_require_execution_evidence_for_each_consumer_context() { + let helper = "path+file:///build/source/host-helper#example-host-helper@0.1.0"; + let mut metadata: Value = + serde_json::from_slice(METADATA).expect("should parse fixture metadata"); + metadata["resolve"]["nodes"][0]["deps"][0]["dep_kinds"] + .as_array_mut() + .expect("should hold dependency kinds") + .push(json!({"kind":null,"target":null})); + let metadata = serde_json::to_vec(&metadata).expect("should encode dual-role metadata"); + let mut observations = observed(); + let script_index = observations + .units + .iter() + .position(|unit| unit.target_kind.iter().any(|kind| kind == "custom-build")) + .expect("should contain script compilation"); + let mut script_unit = observed().units.remove(script_index); + script_unit.package_id = helper.to_owned(); + observations.units.push(script_unit); + let helper_execution = |context, out_dir| { + let mut execution = observed().build_scripts.remove(0); + execution.package_id = helper.to_owned(); + execution.context = context; + execution.out_dir = PathBuf::from(out_dir); + execution + }; + let execution = helper_execution( + UnitContext::Host, + "/build/target/release/build/example-helper/out", + ); + observations.build_scripts.push(execution); + reconcile(&metadata, TREE, &observations, ROOT) + .expect("should accept matching host-only script and consumer"); + + let helper_index = observations + .units + .iter() + .position(|unit| unit.package_id == helper && unit.target_kind == ["lib"]) + .expect("should contain host helper consumer"); + let mut target_unit = observed().units.remove(helper_index); + target_unit.context = UnitContext::Target; + target_unit.features = vec!["target-feature".to_owned()]; + observations.units.push(target_unit); + assert!( + reconcile(&metadata, TREE, &observations, ROOT).is_err(), + "should reject missing execution evidence for the second consuming context" + ); + + observations.build_scripts.push(helper_execution( + UnitContext::Target, + "/build/target/wasm32-wasip1/release/build/example-helper/out", + )); + reconcile(&metadata, TREE, &observations, ROOT) + .expect("should accept separate host and target executions without merging features"); + observations.build_scripts.push(helper_execution( + UnitContext::Target, + "/build/target/wasm32-wasip1/release/build/example-helper-other/out", + )); + reconcile(&metadata, TREE, &observations, ROOT) + .expect("should allow distinct executions within the same consuming context"); +} + +#[test] +fn actual_cross_build_preserves_host_only_dependencies_and_observed_units() { + let observed = observed(); + let graph = reconcile(METADATA, TREE, &observed, ROOT) + .expect("should reconcile actual cross-build graph"); + assert_eq!( + graph.observed_package_ids().len(), + 3, + "should retain observed membership" + ); + assert_eq!( + graph.potential_edges().len(), + 2, + "should retain potential package edges" + ); + assert_eq!( + graph.potential_edges()[0].kinds(), + ["build"], + "should record build dependency transition" + ); + assert!( + observed + .units + .iter() + .any(|unit| unit.context == UnitContext::Host + && unit.package_id.contains("example-host-unix")), + "should preserve observed host-only unit" + ); +} + +#[test] +fn invalid_tree_structure_and_missing_observed_membership_are_rejected() { + for tree in [b"".as_slice(), b"0example-app v0.1.0 (/build/source/app)", b"1example-app v0.1.0 (/build/source/app)\n", b"0example-app v0.1.0 (/build/source/app)\n2example-host-helper v0.1.0 (/build/source/host-helper)\n", b"0example-app v0.1.0 (/build/source/app)\n", b"0example-app v0.1.0 (/wrong)\n", b"0example-app v0.1.0 (/build/source/app)\n0example-app v0.1.0 (/build/source/app)\n"] { + assert!(reconcile(METADATA, tree, &observed(), ROOT).is_err(), "should reject invalid tree or missing compiled nodes"); + } +} + +#[test] +fn unknown_duplicate_and_dev_only_metadata_edges_are_rejected() { + for case in [ + "unknown-node", + "duplicate-node", + "unknown-endpoint", + "missing-edge", + "dev-only", + "ambiguous-source", + "dependencies-disagree", + ] { + let mut meta: Value = + serde_json::from_slice(METADATA).expect("should parse fixture metadata"); + match case { + "unknown-node" => meta["resolve"]["nodes"][0]["id"] = json!("unknown"), + "duplicate-node" => { + let duplicate = meta["resolve"]["nodes"][0].clone(); + meta["resolve"]["nodes"] + .as_array_mut() + .expect("should hold nodes") + .push(duplicate); + } + "unknown-endpoint" => meta["resolve"]["nodes"][0]["deps"][0]["pkg"] = json!("unknown"), + "missing-edge" => { + meta["resolve"]["nodes"][0]["deps"] = json!([]); + meta["resolve"]["nodes"][0]["dependencies"] = json!([]); + } + "dev-only" => { + meta["resolve"]["nodes"][0]["deps"][0]["dep_kinds"][0]["kind"] = json!("dev") + } + "ambiguous-source" => { + let mut duplicate = meta["packages"][0].clone(); + duplicate["id"] = json!("opaque-other-source"); + duplicate["source"] = json!("registry+https://example.com/index"); + meta["packages"] + .as_array_mut() + .expect("should hold packages") + .push(duplicate); + } + "dependencies-disagree" => meta["resolve"]["nodes"][0]["dependencies"] = json!([]), + _ => unreachable!(), + } + let bytes = serde_json::to_vec(&meta).expect("should encode changed metadata"); + assert!( + reconcile(&bytes, TREE, &observed(), ROOT).is_err(), + "should reject {case}" + ); + } +} + +#[test] +fn ordinary_compiler_units_need_reachable_host_or_target_context() { + let mut observed = observed(); + let host = observed + .units + .iter_mut() + .find(|unit| unit.package_id.contains("example-host-unix")) + .expect("should contain host dependency"); + host.context = UnitContext::Target; + assert!( + reconcile(METADATA, TREE, &observed, ROOT).is_err(), + "should reject target unit reachable only through build dependency" + ); +} + +#[test] +fn tree_only_packages_never_create_compiler_membership() { + let mut observed = observed(); + observed.packages.retain(|package| package.id == ROOT); + observed.units.retain(|unit| unit.package_id == ROOT); + let graph = reconcile(METADATA, TREE, &observed, ROOT) + .expect("should retain potential graph without inventing units"); + assert_eq!( + graph.tree_package_ids.len(), + 3, + "should retain potential graph" + ); + assert_eq!( + graph.observed_package_ids, + [ROOT], + "should retain only actual compiler membership" + ); +} + +#[test] +fn catalog_dev_only_entries_never_create_contributors() { + let mut meta: Value = serde_json::from_slice(METADATA).expect("should parse fixture metadata"); + let mut dev = meta["packages"][1].clone(); + dev["id"] = json!("opaque-dev-only"); + dev["name"] = json!("example-dev-only"); + meta["packages"] + .as_array_mut() + .expect("should hold packages") + .push(dev); + meta["resolve"]["nodes"] + .as_array_mut() + .expect("should hold nodes") + .push(json!({"id":"opaque-dev-only", "dependencies":[], "deps":[], "features":["unused"]})); + meta["resolve"]["nodes"][0]["dependencies"] + .as_array_mut() + .expect("should hold dependencies") + .push(json!("opaque-dev-only")); + meta["resolve"]["nodes"][0]["deps"].as_array_mut().expect("should hold edges").push(json!({"name":"example_dev_only", "pkg":"opaque-dev-only", "dep_kinds":[{"kind":"dev", "target":null}]})); + let graph = reconcile( + &serde_json::to_vec(&meta).expect("should encode metadata"), + TREE, + &observed(), + ROOT, + ) + .expect("should exclude uncompiled dev dependency"); + assert!( + !graph + .tree_package_ids + .iter() + .any(|id| id == "opaque-dev-only"), + "should not treat catalog as observed graph" + ); +} + +#[test] +fn disconnected_compiler_membership_is_rejected() { + let mut observed = observed(); + observed.packages[0].id = "opaque-extra-compiler-package".to_owned(); + assert!( + reconcile(METADATA, TREE, &observed, ROOT).is_err(), + "should reject unjoined compiled package identity" + ); +} + +#[test] +fn unused_ambiguous_catalog_names_do_not_reject_selected_tree() { + let mut metadata: Value = + serde_json::from_slice(METADATA).expect("should parse fixture metadata"); + for id in ["opaque-unused-source-a", "opaque-unused-source-b"] { + let mut package = metadata["packages"][0].clone(); + package["name"] = json!("example-unused"); + package["id"] = json!(id); + metadata["packages"] + .as_array_mut() + .expect("should hold packages") + .push(package); + } + reconcile( + &serde_json::to_vec(&metadata).expect("should encode catalog"), + TREE, + &observed(), + ROOT, + ) + .expect("should reject ambiguous identities only when tree membership uses them"); +} + +#[test] +fn dual_role_packages_keep_compiler_features_and_both_contexts() { + let mut meta: Value = serde_json::from_slice(METADATA).expect("should parse fixture metadata"); + meta["resolve"]["nodes"][0]["deps"][0]["dep_kinds"] + .as_array_mut() + .expect("should hold dependency kinds") + .push(json!({"kind":null,"target":null})); + meta["resolve"]["nodes"][1]["features"] = json!(["unused-package-union"]); + let mut messages: Vec = std::str::from_utf8(MESSAGES) + .expect("should hold JSONL") + .lines() + .map(|line| serde_json::from_str(line).expect("should parse compiler message")) + .collect(); + let mut target_unit = messages + .iter() + .find(|message| { + message["reason"] == "compiler-artifact" + && message["package_id"] + .as_str() + .is_some_and(|id| id.contains("example-host-helper")) + }) + .expect("should contain helper compilation") + .clone(); + target_unit["filenames"] = + json!(["/build/target/wasm32-wasip1/release/deps/libexample_host_helper.rlib"]); + target_unit["features"] = json!(["target-feature"]); + messages.insert(0, target_unit); + let stdout: Vec = messages + .iter() + .flat_map(|message| { + let mut bytes = serde_json::to_vec(message).expect("should encode message"); + bytes.push(b'\n'); + bytes + }) + .collect(); + let layout = CargoLayout::new( + ReleaseTarget::Wasm32Wasip1, + PathBuf::from("/build/target"), + PathBuf::from("/build/source"), + ) + .expect("should accept fixture layout"); + let metadata = serde_json::to_vec(&meta).expect("should encode metadata"); + let observed = + observe_cargo(&metadata, &stdout, &layout).expect("should observe dual-role compilation"); + reconcile(&metadata, TREE, &observed, ROOT) + .expect("should reconcile both contexts without fabricating a unit DAG"); + assert!( + observed + .units + .iter() + .any(|unit| unit.features == ["target-feature"] && unit.context == UnitContext::Target), + "should preserve effective target features" + ); + assert!( + !observed.units.iter().any(|unit| unit + .features + .iter() + .any(|feature| feature == "unused-package-union")), + "should never substitute package union features" + ); +} + +#[test] +fn proc_macro_normal_dependency_switches_its_dependencies_to_host() { + let mut meta: Value = serde_json::from_slice(METADATA).expect("should parse fixture metadata"); + meta["resolve"]["nodes"][0]["deps"][0]["dep_kinds"][0]["kind"] = Value::Null; + meta["packages"][1]["targets"][0]["kind"] = json!(["proc-macro"]); + let mut observed = observed(); + let helper = observed + .units + .iter_mut() + .find(|unit| unit.package_id.contains("example-host-helper")) + .expect("should contain helper compilation"); + helper.target_kind = vec!["proc-macro".to_owned()]; + reconcile( + &serde_json::to_vec(&meta).expect("should encode metadata"), + TREE, + &observed, + ROOT, + ) + .expect("should retain host context below proc macro"); +} + +#[test] +fn registry_deduplication_marker_is_not_a_source_presentation() { + let mut meta: Value = serde_json::from_slice(METADATA).expect("should parse fixture metadata"); + meta["packages"][1]["source"] = json!("registry+https://example.com/index"); + let tree = String::from_utf8(TREE.to_vec()) + .expect("should hold tree text") + .replace("(/build/source/host-helper)", "(*)"); + assert!( + reconcile( + &serde_json::to_vec(&meta).expect("should encode metadata"), + tree.as_bytes(), + &observed(), + ROOT + ) + .is_err(), + "should require complete non-deduplicated tree" + ); +} + +#[test] +fn selected_tree_root_requires_a_resolve_node() { + let mut meta: Value = serde_json::from_slice(METADATA).expect("should parse fixture metadata"); + meta["resolve"]["nodes"] = json!([]); + let mut observed = observed(); + observed.packages.retain(|package| package.id == ROOT); + observed.units.retain(|unit| unit.package_id == ROOT); + assert!( + reconcile( + &serde_json::to_vec(&meta).expect("should encode metadata"), + b"0example-app v0.1.0 (/build/source/app)\n", + &observed, + ROOT + ) + .is_err(), + "should reject tree root without resolve node" + ); +} + +#[test] +fn repeated_tree_occurrences_have_a_separate_bound_from_compiler_messages() { + let mut tree = TREE.to_vec(); + for _ in 0..65_536 { + tree.extend_from_slice(b"1example-host-helper v0.1.0 (/build/source/host-helper)\n"); + } + let graph = reconcile(METADATA, &tree, &observed(), ROOT) + .expect("should accept large non-deduplicated dependency trees"); + assert_eq!( + graph.edges.len(), + 2, + "should store unique package edges instead of repeated occurrences" + ); +} + +#[test] +fn tree_occurrence_bound_is_enforced_independently_of_byte_bound() { + let mut metadata: Value = + serde_json::from_slice(METADATA).expect("should parse fixture metadata"); + for (package, name) in metadata["packages"] + .as_array_mut() + .expect("should hold packages") + .iter_mut() + .zip(["a", "b", "c"]) + { + package["name"] = json!(name); + package["version"] = json!("1"); + package["source"] = json!("registry+https://example.com/index"); + } + let mut tree = b"0a v1\n1b v1\n2c v1\n".to_vec(); + for _ in 0..1_048_576 { + tree.extend_from_slice(b"1b v1\n"); + } + assert!( + tree.len() < 16 * 1024 * 1024, + "should exercise occurrence bound before byte bound" + ); + assert!( + reconcile( + &serde_json::to_vec(&metadata).expect("should encode compact metadata"), + &tree, + &observed(), + ROOT + ) + .is_err(), + "should reject excessive tree occurrences" + ); +} diff --git a/crates/trusted-server-release/src/composition/javascript.rs b/crates/trusted-server-release/src/composition/javascript.rs new file mode 100644 index 000000000..1b96648d9 --- /dev/null +++ b/crates/trusted-server-release/src/composition/javascript.rs @@ -0,0 +1,169 @@ +//! Conservative final-byte membership for verified uncompressed JS modules. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use error_stack::Report; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +use super::composition_error; +use crate::documents::{Role, lower_hex, read_bounded}; +use crate::error::ReleaseError; +use crate::javascript::RetainedJsModule; + +/// Selected fixed recipe's required final JS coverage. +#[derive(Clone, Copy)] +pub(crate) enum JsRetentionPolicy { + /// Fastly can serve each prepared module and must retain its complete bytes. + FastlyAll, + /// The operator CLI may discard modules it does not use. + ObserveCli, +} + +/// Checked JS handoff inputs selected by the recipe. +pub(crate) struct JsInputs<'a> { + /// Retained root owned by the successful two-build JS handoff. + pub(crate) retained_root: &'a Path, + /// Module facts exported from that rehashed owning handoff. + pub(crate) modules: &'a [RetainedJsModule], + /// Exact raw handoff inventory identity. + pub(crate) inventory_sha256: &'a str, + /// Required coverage of the fixed asset recipe. + pub(crate) policy: JsRetentionPolicy, +} + +/// Per-artifact retained-module observations, without function reachability claims. +#[derive(Debug, Serialize)] +pub(crate) struct JsMembership { + artifact_sha256: String, + inventory_sha256: String, + modules: Vec, + method: &'static str, +} + +#[derive(Debug, Serialize)] +struct ModuleMembership { + module: RetainedJsModule, + classification: &'static str, +} + +/// Checks complete prepared modules against the exact inspected final bytes. +/// +/// # Errors +/// +/// Rejects changed modules, invalid bindings, incomplete module sets or a +/// Fastly executable missing any required complete module. +pub(crate) fn collect_js( + bytes: &[u8], + artifact_sha256: &str, + inputs: &JsInputs<'_>, +) -> Result> { + if bytes.is_empty() + || bytes.len() as u64 > Role::Executable.limit() + || !lower_hex(artifact_sha256, 64) + || !lower_hex(inputs.inventory_sha256, 64) + || hex::encode(Sha256::digest(bytes)) != artifact_sha256 + || inputs.modules.is_empty() + || inputs.modules.len() > 1024 + { + return Err(composition_error( + "invalid final executable or JS input binding", + )); + } + let bundles = inputs.retained_root.join("bundles"); + for directory in [inputs.retained_root, bundles.as_path()] { + if fs::canonicalize(directory).map_err(composition_error)? != directory { + return Err(composition_error("retained JS directory is aliased")); + } + } + let inventory = read_bounded( + &inputs.retained_root.join("tsjs-inventory.json"), + Role::Metadata, + ) + .map_err(|error| error.change_context(ReleaseError::Composition))?; + if inventory.sha256 != inputs.inventory_sha256 { + return Err(composition_error( + "retained JS inventory differs from the bound identity", + )); + } + let mut ids = BTreeSet::new(); + let mut filenames = BTreeSet::new(); + for module in inputs.modules { + if !ids.insert(module.id()) || !filenames.insert(module.filename()) { + return Err(composition_error("duplicate prepared JS module identity")); + } + } + let actual = fs::read_dir(&bundles) + .map_err(composition_error)? + .map(|entry| { + entry + .map_err(composition_error)? + .file_name() + .into_string() + .map_err(|_| composition_error("JS filename is not UTF-8")) + }) + .collect::, _>>()?; + if actual.iter().map(String::as_str).collect::>() != filenames { + return Err(composition_error( + "prepared JS file set differs from checked module facts", + )); + } + let mut modules = Vec::with_capacity(inputs.modules.len()); + for module in inputs.modules { + let raw = read_bounded(&bundles.join(module.filename()), Role::Bundle) + .map_err(|error| error.change_context(ReleaseError::Composition))?; + if raw.bytes.is_empty() + || raw.sha256 != module.sha256() + || raw.bytes.len() as u64 != module.size() + { + return Err(composition_error( + "prepared JS module changed before final-byte inspection", + )); + } + let retained = bytes + .windows(raw.bytes.len()) + .any(|window| window == raw.bytes); + if !retained && matches!(inputs.policy, JsRetentionPolicy::FastlyAll) { + return Err(composition_error(format!( + "Fastly executable omits complete module {}", + module.id() + ))); + } + modules.push(ModuleMembership { + module: module.clone(), + classification: if retained { + "conservative_embedded_js" + } else { + "js_build_material" + }, + }); + } + Ok(JsMembership { + artifact_sha256: artifact_sha256.to_owned(), + inventory_sha256: inputs.inventory_sha256.to_owned(), + modules, + method: "complete-uncompressed-module-substring; no function reachability claim", + }) +} + +#[cfg(test)] +mod tests; + +impl JsMembership { + /// Returns the exact inspected executable identity. + pub(crate) fn artifact_sha256(&self) -> &str { + &self.artifact_sha256 + } + /// Returns the checked original collector inventory identity. + pub(crate) fn inventory_sha256(&self) -> &str { + &self.inventory_sha256 + } + /// Returns every prepared module with final artifact classification. + pub(crate) fn modules(&self) -> impl Iterator { + self.modules + .iter() + .map(|entry| (&entry.module, entry.classification)) + } +} diff --git a/crates/trusted-server-release/src/composition/javascript/tests.rs b/crates/trusted-server-release/src/composition/javascript/tests.rs new file mode 100644 index 000000000..a16188c45 --- /dev/null +++ b/crates/trusted-server-release/src/composition/javascript/tests.rs @@ -0,0 +1,151 @@ +use std::fs; +use std::os::unix::fs::symlink; + +use sha2::Sha256; + +use super::*; +use crate::javascript::JsBuildCompleted; + +fn collect(bytes: &[u8], policy: JsRetentionPolicy) -> Result> { + let completed = JsBuildCompleted::fixture_for_tests(&"a".repeat(40)); + let modules = completed + .retained_modules() + .expect("should export rehashed module facts"); + let root = fs::canonicalize( + completed + .rehash_for_handoff() + .expect("should retain checked handoff"), + ) + .expect("should use recipe's canonical handoff path"); + collect_js( + bytes, + &hex::encode(Sha256::digest(bytes)), + &JsInputs { + retained_root: &root, + modules: &modules, + inventory_sha256: completed.inventory_sha256(), + policy, + }, + ) +} + +#[test] +fn complete_module_bytes_are_conservative_embedded_contributors() { + let found = collect( + b"binary-prefix\0fictional module\0binary-suffix", + JsRetentionPolicy::FastlyAll, + ) + .expect("should find the complete final module"); + assert_eq!( + found.modules[0].classification, "conservative_embedded_js", + "should label retained bytes conservatively" + ); + assert_eq!( + found.artifact_sha256, + hex::encode(Sha256::digest( + b"binary-prefix\0fictional module\0binary-suffix" + )), + "should bind exact final executable bytes" + ); +} + +#[test] +fn absent_or_partial_cli_modules_remain_build_material() { + for bytes in [ + b"operator binary".as_slice(), + b"fictional modul", + b"ictional module", + b"fictional modUle", + ] { + let absent = collect(bytes, JsRetentionPolicy::ObserveCli) + .expect("should record honestly absent module"); + assert_eq!( + absent.modules[0].classification, "js_build_material", + "should not infer embedding from partial or changed bytes" + ); + assert!( + collect(bytes, JsRetentionPolicy::FastlyAll).is_err(), + "should reject a Fastly artifact missing a complete prepared module" + ); + } +} + +#[test] +fn final_js_membership_rejects_an_unrelated_inventory_identity() { + let completed = JsBuildCompleted::fixture_for_tests(&"a".repeat(40)); + let modules = completed + .retained_modules() + .expect("should export checked module facts"); + let root = fs::canonicalize( + completed + .rehash_for_handoff() + .expect("should retain handoff"), + ) + .expect("should use canonical handoff root"); + let bytes = b"fictional module"; + let digest = hex::encode(Sha256::digest(bytes)); + let mut inputs = JsInputs { + retained_root: &root, + modules: &modules, + inventory_sha256: &"b".repeat(64), + policy: JsRetentionPolicy::ObserveCli, + }; + assert!( + collect_js(bytes, &digest, &inputs).is_err(), + "should reject a digest that does not identify the retained original inventory" + ); + inputs.inventory_sha256 = completed.inventory_sha256(); + collect_js(bytes, &digest, &inputs) + .expect("should accept the exact same bytes with their actual inventory binding"); +} + +#[test] +fn changed_bundle_wrong_final_digest_duplicates_and_aliases_are_rejected() { + let completed = JsBuildCompleted::fixture_for_tests(&"a".repeat(40)); + let modules = completed + .retained_modules() + .expect("should export checked facts"); + let root = fs::canonicalize( + completed + .rehash_for_handoff() + .expect("should retain handoff"), + ) + .expect("should use canonical handoff root"); + let bytes = b"fictional module"; + let digest = hex::encode(Sha256::digest(bytes)); + let inputs = |modules| JsInputs { + retained_root: &root, + modules, + inventory_sha256: completed.inventory_sha256(), + policy: JsRetentionPolicy::ObserveCli, + }; + assert!( + collect_js(bytes, &"b".repeat(64), &inputs(&modules)).is_err(), + "should reject a mismatched final byte identity" + ); + assert!( + collect_js(bytes, &digest, &inputs(&[])).is_err(), + "should reject an omitted prepared module set" + ); + let mut duplicates = modules.clone(); + duplicates.push(modules[0].clone()); + assert!( + collect_js(bytes, &digest, &inputs(&duplicates)).is_err(), + "should reject duplicated module identities" + ); + let bundle = root.join("bundles/tsjs-core.js"); + fs::write(&bundle, b"changed").expect("should change prepared module"); + assert!( + collect_js(bytes, &digest, &inputs(&modules)).is_err(), + "should reject changed prepared module bytes" + ); + fs::remove_file(&bundle).expect("should remove changed bundle"); + let other = tempfile::tempdir().expect("should own external replacement"); + fs::write(other.path().join("module.js"), bytes) + .expect("should write identical replacement bytes"); + symlink(other.path().join("module.js"), &bundle).expect("should create final-file alias"); + assert!( + collect_js(bytes, &digest, &inputs(&modules)).is_err(), + "should reject aliased prepared module" + ); +} diff --git a/crates/trusted-server-release/src/composition/native.rs b/crates/trusted-server-release/src/composition/native.rs new file mode 100644 index 000000000..df23044a3 --- /dev/null +++ b/crates/trusted-server-release/src/composition/native.rs @@ -0,0 +1,682 @@ +//! Candidate native source and archive observations, without release authority. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, Metadata, OpenOptions}; +use std::io::Read as _; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +use super::{ + BuildScriptObservation, CargoObservations, RustPackage, UnitContext, composition_error, + normal_path, +}; +use crate::documents::lower_hex; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; +use crate::tool_tree::tool_tree_digest; + +const ARCHIVE_LIMIT: u64 = 512 * 1024 * 1024; + +/// A source root supplied only by the verified exact-Cargo-ID origin join. +pub(crate) struct NativeSource { + /// SHA-256 of the verified original registry distribution. + pub(crate) original_sha256: String, + /// Host path of the verified prepared package. + pub(crate) source_root: PathBuf, +} + +/// Inputs selected by the recipe, rather than deserialized caller assertions. +pub(crate) struct NativeInputs<'a> { + /// Exact observed Cargo package IDs joined to verified originals. + pub(crate) origins: &'a BTreeMap, + /// Cargo target root as seen by the confined child. + pub(crate) execution_output_root: &'a Path, + /// The same Cargo target root in the host namespace. + pub(crate) host_output_root: &'a Path, + /// Fixed release target of the recipe. + pub(crate) target: ReleaseTarget, + /// Actual selected build environment, including anti-substitution controls. + pub(crate) environment: &'a BTreeMap, + /// Owning attempt's cancellation/deadline check. + pub(crate) check: &'a dyn Fn() -> Result<(), Report>, +} + +/// Candidate native measurements; never an attestation or qualification token. +#[derive(Debug, Serialize)] +pub(crate) struct NativeInventory { + contributors: Vec, + build_tool_package_ids: Vec, + material_native_coverage_complete: bool, +} + +#[derive(Debug, Serialize)] +#[cfg_attr(test, derive(Clone))] +pub(crate) struct NativeContributor { + package_id: String, + name: String, + version: String, + context: UnitContext, + features: Vec, + classification: &'static str, + original_sha256: String, + source_tree_sha256: String, + source_subtrees: Vec, + linked_libs: Vec, + linked_paths: Vec, + archives: Vec, + #[serde(skip)] + source_root: PathBuf, +} + +#[derive(Debug, Serialize)] +#[cfg_attr(test, derive(Clone))] +pub(crate) struct SourceSubtree { + path: &'static str, + sha256: String, +} + +#[derive(Debug, Serialize)] +#[cfg_attr(test, derive(Clone))] +pub(crate) struct NativeArchive { + path: PathBuf, + size: u64, + sha256: String, + #[serde(skip)] + host_path: PathBuf, + #[serde(skip)] + output_root: PathBuf, +} + +/// Source-reviewed rules for the locked native implementations. +struct ReviewedNative { + version: &'static str, + allowed_features: &'static [&'static str], + required_features: &'static [&'static str], + libraries: &'static [&'static str], + source_subtrees: &'static [&'static str], +} + +fn reviewed(name: &str) -> Option { + Some(match name { + "aws-lc-sys" => ReviewedNative { + version: "0.42.0", + // The locked Rust wrapper disables default bindings. Its NASM + // feature can select prebuilt objects only on Windows x86_64, + // outside the selected Linux and macOS build platforms. + allowed_features: &["all-bindings", "default", "prebuilt-nasm"], + required_features: &[], + libraries: &["aws_lc_0_42_0_crypto"], + source_subtrees: &["aws-lc"], + }, + "ring" => ReviewedNative { + version: "0.17.14", + allowed_features: &["alloc", "default", "dev_urandom_fallback", "std"], + required_features: &["alloc"], + // The reviewed ring build script always builds and links its test + // archive, including for release builds. Neither archive's presence + // proves that every member survives final linking. + libraries: &["ring_core_0_17_14_", "ring_core_0_17_14__test"], + source_subtrees: &["crypto", "include", "pregenerated"], + }, + "libsqlite3-sys" => ReviewedNative { + version: "0.38.2", + allowed_features: &[ + "bundled", + "bundled_bindings", + "cc", + "default", + "min_sqlite_version_3_34_1", + "pkg-config", + "vcpkg", + "unlock_notify", + ], + required_features: &["bundled", "bundled_bindings", "cc"], + libraries: &["sqlite3"], + source_subtrees: &["sqlite3"], + }, + _ => return None, + }) +} + +fn real_directory(path: &Path) -> Result<(), Report> { + normal_path(path)?; + let metadata = fs::symlink_metadata(path).map_err(composition_error)?; + let resolved = fs::canonicalize(path).map_err(composition_error)?; + if !metadata.is_dir() || resolved != path { + return Err(composition_error( + "native directory is aliased or not a directory", + )); + } + Ok(()) +} + +fn archive_parent(root: &Path, file: &Path) -> Result<(), Report> { + normal_path(file)?; + real_directory(root)?; + let parent = file + .parent() + .ok_or_else(|| composition_error("native archive has no parent"))?; + let relative = parent.strip_prefix(root).map_err(composition_error)?; + let mut current = root.to_owned(); + for component in relative.components() { + current.push(component); + real_directory(¤t)?; + } + Ok(()) +} + +fn same_archive(before: &Metadata, after: &Metadata) -> bool { + after.is_file() + && before.dev() == after.dev() + && before.ino() == after.ino() + && before.nlink() == after.nlink() + && before.len() == after.len() + && before.mode() == after.mode() + && before.mtime() == after.mtime() + && before.mtime_nsec() == after.mtime_nsec() + && before.ctime() == after.ctime() + && before.ctime_nsec() == after.ctime_nsec() +} + +fn archive_digest( + root: &Path, + path: &Path, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + archive_parent(root, path)?; + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(composition_error)?; + let before = file.metadata().map_err(composition_error)?; + if !before.is_file() || before.nlink() != 1 || before.len() > ARCHIVE_LIMIT { + return Err(composition_error( + "native archive is not a bounded regular single-link file", + )); + } + let mut magic = [0; 8]; + file.read_exact(&mut magic).map_err(composition_error)?; + if &magic != b"!\n" { + return Err(composition_error("native output is not a regular archive")); + } + // Format and content identity must describe the same held descriptor. + let mut hash = Sha256::new(); + hash.update(magic); + let mut size = magic.len() as u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + check()?; + let count = file.read(&mut buffer).map_err(composition_error)?; + if count == 0 { + break; + } + size += count as u64; + if size > before.len() || size > ARCHIVE_LIMIT { + return Err(composition_error("native archive grows while hashing")); + } + hash.update(&buffer[..count]); + } + check()?; + if size != before.len() + || !same_archive(&before, &file.metadata().map_err(composition_error)?) + || !same_archive( + &before, + &fs::symlink_metadata(path).map_err(composition_error)?, + ) + { + return Err(composition_error( + "native archive content, metadata or path changes while hashing", + )); + } + archive_parent(root, path)?; + Ok(crate::documents::FileDigest { + sha256: hex::encode(hash.finalize()), + size, + mode: before.mode(), + }) +} + +fn source_digest( + path: &Path, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + real_directory(path)?; + let sha256 = tool_tree_digest(path, check) + .map_err(|error| error.change_context(ReleaseError::Composition))?; + real_directory(path)?; + Ok(sha256) +} + +fn check_environment(name: &str, inputs: &NativeInputs<'_>) -> Result<(), Report> { + if name == "aws-lc-sys" { + let target_control = format!( + "AWS_LC_SYS_USE_SYSTEM_{}", + inputs.target.triple().replace('-', "_") + ); + for control in ["AWS_LC_SYS_USE_SYSTEM", target_control.as_str()] { + if inputs.environment.get(control).map(String::as_str) != Some("0") { + return Err(composition_error( + "AWS-LC system discovery is not explicitly disabled", + )); + } + } + if inputs.environment.iter().any(|(key, value)| { + key.starts_with("AWS_LC_SYS_") + && !(key.starts_with("AWS_LC_SYS_USE_SYSTEM") && value == "0") + && !(inputs.target == ReleaseTarget::X86_64UnknownLinuxGnu + && matches!( + key.as_str(), + "AWS_LC_SYS_CMAKE_BUILDER" + | "AWS_LC_SYS_CMAKE_BUILDER_x86_64_unknown_linux_gnu" + ) + && value == "0") + }) { + return Err(composition_error( + "AWS-LC has an unreviewed environment selection", + )); + } + } + if name == "libsqlite3-sys" { + if inputs + .environment + .get("LIBSQLITE3_SYS_USE_PKG_CONFIG") + .map(String::as_str) + != Some("0") + { + return Err(composition_error( + "SQLite system discovery is not explicitly disabled", + )); + } + if inputs.environment.keys().any(|key| { + key.starts_with("SQLITE3_") + || key.starts_with("SQLCIPHER_") + || key.starts_with("LIBSQLITE3_") && key != "LIBSQLITE3_SYS_USE_PKG_CONFIG" + }) { + return Err(composition_error( + "SQLite has an unreviewed environment selection", + )); + } + } + Ok(()) +} + +fn features_for( + observations: &CargoObservations, + package: &RustPackage, + script: &BuildScriptObservation, + rule: &ReviewedNative, +) -> Result, Report> { + let mut matches = observations.units.iter().filter(|unit| { + unit.package_id == script.package_id + && unit.context == script.context + && !unit.target_kind.iter().any(|kind| kind == "custom-build") + }); + let first = matches + .next() + .ok_or_else(|| composition_error("native script has no observed consuming unit"))?; + let conflicting = matches.any(|unit| unit.features != first.features); + let unreviewed = first + .features + .iter() + .any(|feature| !rule.allowed_features.contains(&feature.as_str())); + let missing = rule + .required_features + .iter() + .any(|feature| !first.features.iter().any(|actual| actual == feature)); + if conflicting || unreviewed || missing { + let observed: Vec = first + .features + .iter() + .take(16) + .map(|feature| feature.chars().take(96).collect()) + .collect(); + return Err(composition_error(format!( + "native package {}@{} {:?}: conflicting_units={conflicting}, unreviewed_features={unreviewed}, missing_required_features={missing}; observed={observed:?} ({} total); required={:?}; allowed={:?}", + package.name.chars().take(96).collect::(), + package.version.chars().take(96).collect::(), + script.context, + first.features.len(), + rule.required_features, + rule.allowed_features, + ))); + } + Ok(first.features.clone()) +} + +fn search_paths( + script: &BuildScriptObservation, + inputs: &NativeInputs<'_>, +) -> Result, Report> { + let release = match script.context { + UnitContext::Host => inputs.execution_output_root.join("release"), + UnitContext::Target => inputs + .execution_output_root + .join(inputs.target.triple()) + .join("release"), + }; + normal_path(&script.out_dir)?; + let relative = script + .out_dir + .strip_prefix(release.join("build")) + .map_err(composition_error)?; + let components: Vec<_> = relative.components().collect(); + if components.len() != 2 || components[1].as_os_str() != "out" { + return Err(composition_error( + "native OUT_DIR differs from the selected Cargo layout", + )); + } + let mut paths = BTreeMap::new(); + for linked in &script.linked_paths { + let path = PathBuf::from( + linked + .strip_prefix("native=") + .ok_or_else(|| composition_error("unreviewed native search directive"))?, + ); + normal_path(&path)?; + if !path.starts_with(&script.out_dir) { + return Err(composition_error( + "native search path escapes the observed OUT_DIR", + )); + } + let relative = path + .strip_prefix(inputs.execution_output_root) + .map_err(composition_error)?; + let host = inputs.host_output_root.join(relative); + real_directory(&host)?; + paths.insert(path, host); + } + if paths.is_empty() { + return Err(composition_error( + "native script has no observed archive search path", + )); + } + Ok(paths.into_iter().collect()) +} + +fn contributor( + observations: &CargoObservations, + package: &RustPackage, + script: &BuildScriptObservation, + inputs: &NativeInputs<'_>, +) -> Result> { + let rule = reviewed(&package.name) + .ok_or_else(|| composition_error("unreviewed native package origin"))?; + if package.version != rule.version { + return Err(composition_error( + "native package version has not been reviewed", + )); + } + check_environment(&package.name, inputs)?; + let features = features_for(observations, package, script, &rule)?; + let source = inputs + .origins + .get(&package.id) + .ok_or_else(|| composition_error("native package has no verified exact-ID original"))?; + if !lower_hex(&source.original_sha256, 64) { + return Err(composition_error("native original has no SHA-256 identity")); + } + let expected: BTreeSet<_> = rule + .libraries + .iter() + .map(|name| format!("static={name}")) + .collect(); + if script.linked_libs.iter().collect::>() != expected.iter().collect() + || script.linked_libs.len() != expected.len() + { + return Err(composition_error( + "native link directives differ from reviewed static archives", + )); + } + let searches = search_paths(script, inputs)?; + let mut archives = Vec::new(); + for library in rule.libraries { + let filename = format!("lib{library}.a"); + let mut candidates = Vec::new(); + for (execution, host) in &searches { + let host_path = host.join(&filename); + match fs::symlink_metadata(&host_path) { + Ok(_) => candidates.push((execution.join(&filename), host_path)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(composition_error(error)), + } + } + let [(path, host_path)] = candidates.as_slice() else { + return Err(composition_error( + "native archive is missing or ambiguous across search paths", + )); + }; + let identity = archive_digest(inputs.host_output_root, host_path, inputs.check)?; + archives.push(NativeArchive { + path: path.clone(), + size: identity.size, + sha256: identity.sha256, + host_path: host_path.clone(), + output_root: inputs.host_output_root.to_owned(), + }); + } + let source_tree_sha256 = source_digest(&source.source_root, inputs.check)?; + let source_subtrees = rule + .source_subtrees + .iter() + .map(|path| { + source_digest(&source.source_root.join(path), inputs.check) + .map(|sha256| SourceSubtree { path, sha256 }) + }) + .collect::, _>>()?; + Ok(NativeContributor { + package_id: package.id.clone(), + name: package.name.clone(), + version: package.version.clone(), + context: script.context, + features, + classification: match script.context { + UnitContext::Host => "build_tool_native_contributor", + UnitContext::Target => "conservative_embedded_contributor", + }, + original_sha256: source.original_sha256.clone(), + source_tree_sha256, + source_subtrees, + linked_libs: script.linked_libs.clone(), + linked_paths: script.linked_paths.clone(), + archives, + source_root: source.source_root.clone(), + }) +} + +/// Captures reviewed native outputs and their verified original sources. +/// +/// # Errors +/// +/// Rejects unknown native origins, features, directives or substitutions, unsafe +/// paths and absent outputs. Toolchain and final platform evidence are separate. +pub(crate) fn collect( + observations: &CargoObservations, + inputs: &NativeInputs<'_>, +) -> Result> { + (inputs.check)()?; + normal_path(inputs.execution_output_root)?; + real_directory(inputs.host_output_root)?; + let mut contributors = Vec::new(); + let mut captured = BTreeSet::new(); + for script in &observations.build_scripts { + (inputs.check)()?; + let package = observations + .packages + .iter() + .find(|package| package.id == script.package_id) + .ok_or_else(|| composition_error("native script has no observed package"))?; + if reviewed(&package.name).is_some() + || !script.linked_libs.is_empty() + || !script.linked_paths.is_empty() + { + if !captured.insert((&script.package_id, script.context == UnitContext::Target)) { + return Err(composition_error("native script context is duplicated")); + } + contributors.push(contributor(observations, package, script, inputs)?); + } + } + for unit in &observations.units { + let package = observations + .packages + .iter() + .find(|package| package.id == unit.package_id) + .ok_or_else(|| composition_error("native unit has no observed package"))?; + if reviewed(&package.name).is_some() + && !unit.target_kind.iter().any(|kind| kind == "custom-build") + && !captured.contains(&(&unit.package_id, unit.context == UnitContext::Target)) + { + return Err(composition_error( + "observed native package has no captured build-script execution", + )); + } + } + let build_tool_package_ids = observations + .units + .iter() + .filter(|unit| unit.context == UnitContext::Host) + .map(|unit| unit.package_id.clone()) + .collect::>() + .into_iter() + .collect(); + (inputs.check)()?; + Ok(NativeInventory { + contributors, + build_tool_package_ids, + material_native_coverage_complete: false, + }) +} + +impl NativeInventory { + /// Rechecks the native source trees and actual output archives before handoff. + /// + /// # Errors + /// + /// Rejects changed or aliased files, unsafe paths and an expired attempt. + pub(crate) fn rehash( + &self, + check: &dyn Fn() -> Result<(), Report>, + ) -> Result<(), Report> { + check()?; + for contributor in &self.contributors { + if source_digest(&contributor.source_root, check)? != contributor.source_tree_sha256 { + return Err(composition_error( + "native prepared source tree changed after capture", + )); + } + for archive in &contributor.archives { + let identity = archive_digest(&archive.output_root, &archive.host_path, check)?; + if identity.sha256 != archive.sha256 || identity.size != archive.size { + return Err(composition_error( + "native output archive changed after capture", + )); + } + } + } + check() + } +} + +#[cfg(test)] +mod tests; + +impl NativeInventory { + /// Returns measured native inputs without granting coverage completeness. + pub(crate) fn contributors(&self) -> &[NativeContributor] { + &self.contributors + } +} + +impl NativeContributor { + /// Returns the exact observed Cargo package ID for joining only. + pub(crate) fn package_id(&self) -> &str { + &self.package_id + } + /// Returns the observed Cargo wrapper package name. + pub(crate) fn name(&self) -> &str { + &self.name + } + /// Returns the observed Cargo wrapper package version. + pub(crate) fn version(&self) -> &str { + &self.version + } + /// Returns its observed consuming compiler context. + pub(crate) fn context(&self) -> UnitContext { + self.context + } + /// Returns effective consuming-unit features. + pub(crate) fn features(&self) -> &[String] { + &self.features + } + /// Returns its conservative collector classification. + pub(crate) fn classification(&self) -> &str { + self.classification + } + /// Returns the verified original registry archive digest. + pub(crate) fn original_sha256(&self) -> &str { + &self.original_sha256 + } + /// Returns structured prepared-source tree evidence, not a raw-file checksum. + pub(crate) fn source_tree_sha256(&self) -> &str { + &self.source_tree_sha256 + } + /// Returns reviewed structured native source subtree observations. + pub(crate) fn source_subtrees(&self) -> &[SourceSubtree] { + &self.source_subtrees + } + /// Returns observed reviewed static-link library directives. + pub(crate) fn linked_libs(&self) -> &[String] { + &self.linked_libs + } + /// Returns measured static output archives with private paths inaccessible. + pub(crate) fn archives(&self) -> &[NativeArchive] { + &self.archives + } + /// Returns whether this wrapper name and version have reviewed native rules. + pub(crate) fn reviewed_wrapper(&self) -> bool { + reviewed(&self.name).is_some_and(|rule| rule.version == self.version) + } +} + +impl SourceSubtree { + /// Returns the reviewed source-relative subtree path. + pub(crate) fn path(&self) -> &str { + self.path + } + /// Returns structured subtree evidence, not a raw-file checksum. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } +} + +impl NativeArchive { + /// Returns the logical archive filename without exposing output roots. + pub(crate) fn filename(&self) -> Option<&str> { + self.path.file_name().and_then(|name| name.to_str()) + } + /// Returns measured static archive length. + pub(crate) fn size(&self) -> u64 { + self.size + } + /// Returns the raw static-output archive digest, distinct from its source. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } +} + +#[cfg(test)] +pub(crate) fn fixture_for_projection( + name: &str, + context: UnitContext, +) -> (NativeInventory, CargoObservations) { + tests::fixture_for_projection(name, context) +} + +/// Returns the reviewed Cargo wrapper version for a native implementation. +pub(crate) fn reviewed_wrapper_version(name: &str) -> Option<&'static str> { + reviewed(name).map(|rule| rule.version) +} diff --git a/crates/trusted-server-release/src/composition/native/tests.rs b/crates/trusted-server-release/src/composition/native/tests.rs new file mode 100644 index 000000000..c5473fbb2 --- /dev/null +++ b/crates/trusted-server-release/src/composition/native/tests.rs @@ -0,0 +1,664 @@ +use std::cell::Cell; +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; + +use serde_json::json; +use sha2::Sha256; +use tempfile::TempDir; + +use super::*; +use crate::composition::{ + BuildScriptObservation, CargoObservations, RustPackage, RustUnit, UnitContext, +}; + +#[test] +fn reviewed_native_wrapper_versions_are_present_in_the_source_lockfile() { + let lock: toml::Value = toml::from_str(include_str!("../../../../../Cargo.lock")) + .expect("should parse the source lockfile"); + let packages = lock["package"] + .as_array() + .expect("should hold locked packages"); + for name in ["aws-lc-sys", "ring", "libsqlite3-sys"] { + let reviewed = reviewed_wrapper_version(name).expect("should review native wrapper"); + assert!( + packages.iter().any(|package| { + package["name"].as_str() == Some(name) + && package["version"].as_str() == Some(reviewed) + }), + "should retain a source-locked version matching reviewed native wrapper {name}" + ); + } +} + +struct Fixture { + _temporary: TempDir, + root: PathBuf, + observations: CargoObservations, + origins: BTreeMap, + environment: BTreeMap, +} + +impl Fixture { + fn new(name: &str) -> Self { + let temporary = tempfile::tempdir().expect("should create native fixture"); + let root = fs::canonicalize(temporary.path()).expect("should resolve fixture root"); + let (version, features, libraries, directories): (&str, &[&str], &[&str], &[&str]) = + match name { + "aws-lc-sys" => ( + "0.42.0", + &["all-bindings", "default"], + &["aws_lc_0_42_0_crypto"], + &["aws-lc"], + ), + "ring" => ( + "0.17.14", + &["alloc", "default", "dev_urandom_fallback"], + &["ring_core_0_17_14_", "ring_core_0_17_14__test"], + &["crypto", "include", "pregenerated"], + ), + "libsqlite3-sys" => ( + "0.38.2", + &[ + "bundled", + "bundled_bindings", + "cc", + "default", + "min_sqlite_version_3_34_1", + "pkg-config", + "vcpkg", + ], + &["sqlite3"], + &["sqlite3"], + ), + _ => panic!("should use a reviewed fixture package"), + }; + let id = format!("registry+https://example.com/index#{name}@{version}"); + let package_root = root.join("vendor").join(name); + fs::create_dir_all(&package_root).expect("should create source root"); + fs::write(package_root.join("Cargo.toml"), "fictional manifest") + .expect("should write manifest"); + for directory in directories { + let path = package_root.join(directory); + fs::create_dir(&path).expect("should create native source subtree"); + fs::write(path.join("source.c"), "fictional source").expect("should write source"); + } + let output = root.join("target/aarch64-apple-darwin/release/build/native/out"); + fs::create_dir_all(&output).expect("should create OUT_DIR"); + for library in libraries { + fs::write( + output.join(format!("lib{library}.a")), + b"!\nfictional archive", + ) + .expect("should write native archive"); + } + let features: Vec<_> = features.iter().map(|value| (*value).to_owned()).collect(); + let observations = CargoObservations { + packages: vec![RustPackage { + id: id.clone(), + name: name.to_owned(), + version: version.to_owned(), + source: Some("registry+https://example.com/index".to_owned()), + manifest_path: package_root.join("Cargo.toml"), + license: None, + }], + units: vec![RustUnit { + package_id: id.clone(), + target_name: name.to_owned(), + target_kind: vec!["lib".to_owned()], + crate_types: vec!["lib".to_owned()], + context: UnitContext::Target, + features, + profile: json!({"test":false}), + filenames: vec![], + executable: None, + }], + build_scripts: vec![BuildScriptObservation { + package_id: id.clone(), + context: UnitContext::Target, + out_dir: output.clone(), + linked_libs: libraries + .iter() + .map(|library| format!("static={library}")) + .collect(), + linked_paths: vec![format!("native={}", output.display())], + cfgs: vec![], + environment: vec![], + }], + }; + let origins = BTreeMap::from([( + id, + NativeSource { + original_sha256: "a".repeat(64), + source_root: package_root, + }, + )]); + let environment = BTreeMap::from([ + ("AWS_LC_SYS_USE_SYSTEM".to_owned(), "0".to_owned()), + ( + "AWS_LC_SYS_USE_SYSTEM_aarch64_apple_darwin".to_owned(), + "0".to_owned(), + ), + ("LIBSQLITE3_SYS_USE_PKG_CONFIG".to_owned(), "0".to_owned()), + ]); + Self { + _temporary: temporary, + root, + observations, + origins, + environment, + } + } + + fn collect(&self) -> Result> { + collect( + &self.observations, + &NativeInputs { + origins: &self.origins, + execution_output_root: &self.root.join("target"), + host_output_root: &self.root.join("target"), + target: ReleaseTarget::Aarch64AppleDarwin, + environment: &self.environment, + check: &|| Ok(()), + }, + ) + } + + fn archive(&self) -> PathBuf { + self.observations.build_scripts[0].out_dir.join( + match self.observations.packages[0].name.as_str() { + "aws-lc-sys" => "libaws_lc_0_42_0_crypto.a", + "ring" => "libring_core_0_17_14_.a", + _ => "libsqlite3.a", + }, + ) + } +} + +#[test] +fn reviewed_aws_lc_bindings_profiles_keep_verified_static_archive_evidence() { + for features in [ + vec![], + vec!["prebuilt-nasm"], + vec!["all-bindings", "default"], + ] { + let mut fixture = Fixture::new("aws-lc-sys"); + fixture.observations.units[0].features = features + .iter() + .map(|feature| (*feature).to_owned()) + .collect(); + let evidence = fixture + .collect() + .expect("should accept reviewed bindings profiles for supported Unix targets"); + assert_eq!( + evidence.contributors[0].features, fixture.observations.units[0].features, + "should preserve actual effective features without enabling extra bindings" + ); + assert_eq!( + evidence.contributors[0].linked_libs, + ["static=aws_lc_0_42_0_crypto"], + "should preserve the verified static crypto archive requirement" + ); + assert_eq!( + evidence.contributors[0].archives.len(), + 1, + "should retain the measured native archive" + ); + } +} + +#[test] +fn native_feature_errors_identify_package_context_and_bounded_observed_features() { + let mut fixture = Fixture::new("aws-lc-sys"); + fixture.observations.units[0] + .features + .push("fips".to_owned()); + let error = format!( + "{:?}", + fixture + .collect() + .expect_err("should reject unreviewed features") + ); + assert!( + error.contains("aws-lc-sys") && error.contains("fips") && error.contains("Target"), + "should diagnose the exact package, context and rejected feature: {error}" + ); + fixture.observations.units[0].features = vec!["x".repeat(8192); 128]; + let error = format!( + "{:?}", + fixture + .collect() + .expect_err("should reject excessive unknown feature text") + ); + assert!( + error.len() < 4096, + "should bound native feature diagnostics" + ); +} + +#[test] +fn archive_format_and_digest_cannot_come_from_different_inodes() { + let temporary = tempfile::tempdir().expect("should create native archive fixture"); + let root = fs::canonicalize(temporary.path()).expect("should resolve fixture root"); + let path = root.join("libexample.a"); + fs::write(&path, b"!\nfictional archive").expect("should write valid archive header"); + let replaced = Cell::new(false); + let check = || { + if !replaced.replace(true) { + let replacement = root.join("replacement"); + fs::write(&replacement, b"fictional non-archive replacement") + .expect("should write replacement bytes"); + fs::rename(replacement, &path).expect("should replace archive after its format check"); + } + Ok(()) + }; + assert!( + archive_digest(&root, &path, &check).is_err(), + "should reject format and digest observations from different files" + ); + assert!(replaced.get(), "should exercise the replacement window"); +} + +#[test] +fn captures_actual_archives_and_verified_source_materials_for_all_reviewed_origins() { + for name in ["aws-lc-sys", "ring", "libsqlite3-sys"] { + let fixture = Fixture::new(name); + let inventory = fixture + .collect() + .expect("should capture reviewed native contributors"); + let value = serde_json::to_value(inventory).expect("should serialize observations"); + let contributor = &value["contributors"][0]; + assert_eq!( + contributor["original_sha256"], + "a".repeat(64), + "should bind verified original source" + ); + assert_eq!( + contributor["archives"][0]["sha256"], + hex::encode(Sha256::digest(b"!\nfictional archive")), + "should hash actual produced archive" + ); + assert_eq!( + contributor["classification"], "conservative_embedded_contributor", + "should not assert exact linked members" + ); + assert_eq!( + value["material_native_coverage_complete"], false, + "should remain candidate observations" + ); + assert!( + contributor["source_tree_sha256"] + .as_str() + .is_some_and(|value| value.len() == 64), + "should bind the prepared source tree" + ); + assert!( + contributor["source_subtrees"] + .as_array() + .is_some_and(|value| !value.is_empty()), + "should identify reviewed native subtrees" + ); + assert_eq!( + contributor["archives"] + .as_array() + .expect("should have archives") + .len(), + if name == "ring" { 2 } else { 1 }, + "should include both ring release archives" + ); + } +} + +#[test] +fn rejects_system_substitution_unknown_origins_features_and_missing_outputs() { + let mut fixture = Fixture::new("aws-lc-sys"); + fixture + .environment + .insert("AWS_LC_SYS_USE_SYSTEM".to_owned(), "1".to_owned()); + assert!( + fixture.collect().is_err(), + "should reject AWS-LC system substitution" + ); + let mut fixture = Fixture::new("libsqlite3-sys"); + fixture.observations.units[0] + .features + .push("loadable_extension".to_owned()); + assert!( + fixture.collect().is_err(), + "should reject unreviewed SQLite feature" + ); + let mut fixture = Fixture::new("ring"); + fixture.origins.clear(); + assert!( + fixture.collect().is_err(), + "should require a verified origin join" + ); + let fixture = Fixture::new("ring"); + fs::remove_file(fixture.archive()).expect("should remove output archive"); + assert!( + fixture.collect().is_err(), + "should require actual output archives" + ); +} + +#[test] +fn rejects_archive_aliases_hardlinks_and_link_search_escapes() { + let fixture = Fixture::new("ring"); + let archive = fixture.archive(); + let other = fixture.root.join("foreign.a"); + fs::rename(&archive, &other).expect("should move archive"); + symlink(&other, &archive).expect("should create alias"); + assert!( + fixture.collect().is_err(), + "should reject a leaf archive alias" + ); + fs::remove_file(&archive).expect("should remove alias"); + fs::hard_link(&other, &archive).expect("should create hardlink"); + assert!( + fixture.collect().is_err(), + "should reject externally rewritable archive" + ); + let mut fixture = Fixture::new("libsqlite3-sys"); + fixture.observations.build_scripts[0].linked_paths = vec!["native=/usr/lib".to_owned()]; + assert!( + fixture.collect().is_err(), + "should reject system search substitution" + ); +} + +#[test] +fn rejects_unknown_native_scripts_and_missing_native_execution() { + let mut fixture = Fixture::new("ring"); + fixture.observations.packages[0].name = "fictional-native".to_owned(); + assert!( + fixture.collect().is_err(), + "should reject an unknown native library origin" + ); + let mut fixture = Fixture::new("ring"); + fixture.observations.build_scripts.clear(); + assert!( + fixture.collect().is_err(), + "should reject an observed native unit without captured output" + ); +} + +#[test] +fn host_native_archives_are_build_tools_instead_of_target_contributors() { + let mut fixture = Fixture::new("ring"); + let old = fixture.observations.build_scripts[0].out_dir.clone(); + let output = fixture.root.join("target/release/build/native/out"); + fs::create_dir_all(output.parent().expect("should have build directory")) + .expect("should create host build directory"); + fs::rename(old, &output).expect("should move host outputs"); + fixture.observations.units[0].context = UnitContext::Host; + fixture.observations.build_scripts[0].context = UnitContext::Host; + fixture.observations.build_scripts[0].out_dir = output.clone(); + fixture.observations.build_scripts[0].linked_paths = + vec![format!("native={}", output.display())]; + let value = serde_json::to_value( + fixture + .collect() + .expect("should capture host native outputs"), + ) + .expect("should serialize host observations"); + assert_eq!( + value["contributors"][0]["classification"], "build_tool_native_contributor", + "should distinguish host build tools from target contributors" + ); + assert_eq!( + value["build_tool_package_ids"][0], fixture.observations.packages[0].id, + "should list observed host build tools" + ); +} + +#[test] +fn rehash_rejects_archive_and_prepared_source_mutation() { + let fixture = Fixture::new("ring"); + let inventory = fixture.collect().expect("should capture native outputs"); + inventory + .rehash(&|| Ok(())) + .expect("should rehash unchanged native evidence"); + fs::write(fixture.archive(), b"!\nchanged archive").expect("should alter archive"); + assert!( + inventory.rehash(&|| Ok(())).is_err(), + "should reject archive changed after capture" + ); + + let fixture = Fixture::new("libsqlite3-sys"); + let inventory = fixture.collect().expect("should capture SQLite source"); + let source = &fixture + .origins + .values() + .next() + .expect("should have origin") + .source_root; + fs::write(source.join("sqlite3/source.c"), "changed fictional source") + .expect("should change native source"); + assert!( + inventory.rehash(&|| Ok(())).is_err(), + "should reject native source changed after capture" + ); +} + +#[test] +fn maps_observed_execution_search_paths_to_host_outputs() { + let mut fixture = Fixture::new("ring"); + let host_root = fixture.root.join("target"); + let execution_root = PathBuf::from("/boundary/build/target"); + let script = &mut fixture.observations.build_scripts[0]; + script.out_dir = execution_root.join( + script + .out_dir + .strip_prefix(&host_root) + .expect("should be below host root"), + ); + script.linked_paths = vec![format!("native={}", script.out_dir.display())]; + let value = serde_json::to_value( + collect( + &fixture.observations, + &NativeInputs { + origins: &fixture.origins, + execution_output_root: &execution_root, + host_output_root: &host_root, + target: ReleaseTarget::Aarch64AppleDarwin, + environment: &fixture.environment, + check: &|| Ok(()), + }, + ) + .expect("should translate boundary namespace"), + ) + .expect("should serialize boundary observations"); + assert!( + value["contributors"][0]["archives"][0]["path"] + .as_str() + .expect("should have archive path") + .starts_with("/boundary/build/target/"), + "should preserve observed namespace rather than invent host observations" + ); +} + +#[test] +fn rejects_ancestor_aliases_ambiguous_searches_and_cross_context_outputs() { + let fixture = Fixture::new("ring"); + let output = fixture.observations.build_scripts[0].out_dir.clone(); + let relocated = fixture.root.join("relocated-out"); + fs::rename(&output, &relocated).expect("should move OUT_DIR"); + symlink(&relocated, &output).expect("should alias OUT_DIR"); + assert!( + fixture.collect().is_err(), + "should reject an ancestor alias" + ); + + let mut fixture = Fixture::new("ring"); + let second = fixture.observations.build_scripts[0] + .out_dir + .join("duplicate"); + fs::create_dir(&second).expect("should create duplicate search directory"); + fs::copy(fixture.archive(), second.join("libring_core_0_17_14_.a")) + .expect("should duplicate archive candidate"); + fixture.observations.build_scripts[0] + .linked_paths + .push(format!("native={}", second.display())); + assert!( + fixture.collect().is_err(), + "should reject ambiguous archive resolution" + ); + + let mut fixture = Fixture::new("ring"); + fixture.observations.build_scripts[0].context = UnitContext::Host; + fixture.observations.units[0].context = UnitContext::Host; + assert!( + fixture.collect().is_err(), + "should reject target outputs labeled host context" + ); +} + +#[test] +fn rejects_unreviewed_native_controls_directives_versions_and_disabled_bundling() { + for feature in ["fips", "bindgen", "ssl", "asan"] { + let mut fixture = Fixture::new("aws-lc-sys"); + fixture.observations.units[0] + .features + .push(feature.to_owned()); + assert!( + fixture.collect().is_err(), + "should reject AWS-LC feature {feature}" + ); + } + let mut fixture = Fixture::new("aws-lc-sys"); + fixture.environment.insert( + "AWS_LC_SYS_SYSTEM_DIR".to_owned(), + "/example/system".to_owned(), + ); + assert!( + fixture.collect().is_err(), + "should reject explicit AWS-LC system material" + ); + let mut fixture = Fixture::new("libsqlite3-sys"); + fixture + .environment + .insert("LIBSQLITE3_SYS_USE_PKG_CONFIG".to_owned(), "1".to_owned()); + assert!( + fixture.collect().is_err(), + "should reject SQLite system discovery" + ); + let mut fixture = Fixture::new("libsqlite3-sys"); + fixture.observations.units[0] + .features + .retain(|feature| feature != "bundled"); + assert!( + fixture.collect().is_err(), + "should require bundled SQLite source" + ); + let mut fixture = Fixture::new("ring"); + fixture.observations.build_scripts[0].linked_libs[0] = "dylib=ring_core_0_17_14_".to_owned(); + assert!( + fixture.collect().is_err(), + "should reject dynamic native substitution" + ); + let mut fixture = Fixture::new("ring"); + fixture.observations.packages[0].version = "0.17.15".to_owned(); + assert!( + fixture.collect().is_err(), + "should require review for changed native version" + ); +} + +#[test] +fn rejects_invalid_material_archive_source_alias_and_missing_native_subtree() { + let mut fixture = Fixture::new("ring"); + fixture + .origins + .values_mut() + .next() + .expect("should have origin") + .original_sha256 = "unverified".to_owned(); + assert!( + fixture.collect().is_err(), + "should require a complete original SHA-256" + ); + let fixture = Fixture::new("ring"); + fs::write(fixture.archive(), "fictional non-archive").expect("should replace output"); + assert!( + fixture.collect().is_err(), + "should reject a file without archive magic" + ); + let mut fixture = Fixture::new("ring"); + let source = &mut fixture + .origins + .values_mut() + .next() + .expect("should have origin") + .source_root; + let alias = fixture.root.join("source-alias"); + symlink(&source, &alias).expect("should create source alias"); + *source = alias; + assert!( + fixture.collect().is_err(), + "should reject an aliased prepared source root" + ); + let fixture = Fixture::new("libsqlite3-sys"); + let source = &fixture + .origins + .values() + .next() + .expect("should have origin") + .source_root; + fs::remove_dir_all(source.join("sqlite3")).expect("should remove native source subtree"); + assert!( + fixture.collect().is_err(), + "should require the reviewed native source subtree" + ); +} + +#[test] +fn checks_attempt_deadlines_during_collection_and_rehash() { + let fixture = Fixture::new("ring"); + let inventory = fixture.collect().expect("should capture native outputs"); + assert!( + inventory + .rehash(&|| Err(composition_error("fictional expired attempt"))) + .is_err(), + "should reject an expired handoff attempt" + ); + let result = collect( + &fixture.observations, + &NativeInputs { + origins: &fixture.origins, + execution_output_root: &fixture.root.join("target"), + host_output_root: &fixture.root.join("target"), + target: ReleaseTarget::Aarch64AppleDarwin, + environment: &fixture.environment, + check: &|| Err(composition_error("fictional cancelled attempt")), + }, + ); + assert!(result.is_err(), "should reject a cancelled capture attempt"); +} + +pub(super) fn fixture_for_projection( + name: &str, + context: UnitContext, +) -> (NativeInventory, CargoObservations) { + let mut fixture = Fixture::new(name); + if context == UnitContext::Host { + let output = fixture.root.join("target/release/build/native/out"); + fs::create_dir_all(&output).expect("should create host output"); + for entry in fs::read_dir(&fixture.observations.build_scripts[0].out_dir) + .expect("should list archives") + { + let entry = entry.expect("should read archive entry"); + fs::copy(entry.path(), output.join(entry.file_name())) + .expect("should copy host archive"); + } + fixture.observations.units[0].context = context; + fixture.observations.build_scripts[0].context = context; + fixture.observations.build_scripts[0].out_dir = output.clone(); + fixture.observations.build_scripts[0].linked_paths = + vec![format!("native={}", output.display())]; + } + let inventory = fixture + .collect() + .expect("should collect owner-checked native fixture"); + (inventory, fixture.observations) +} diff --git a/crates/trusted-server-release/src/composition/origin.rs b/crates/trusted-server-release/src/composition/origin.rs new file mode 100644 index 000000000..a3d355d7f --- /dev/null +++ b/crates/trusted-server-release/src/composition/origin.rs @@ -0,0 +1,403 @@ +//! Exact compiled Cargo identities joined to approved source and verified originals. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde::Serialize; + +use super::{CargoObservations, composition_error, native::NativeSource}; +use crate::documents::{Role, read_bounded}; +use crate::error::ReleaseError; +use crate::materials::{CargoOrigin, PreparedCargoMaterials}; +use crate::recipe::ExecutionPaths; +use crate::source::{ApprovedSource, FreshSource, FreshSourceVerification}; + +/// Approved tracked manifests bound to one independent source checkout. +pub(crate) struct WorkspaceOrigins { + root: PathBuf, + source_commit: String, + cargo_lock_sha256: String, + manifests: BTreeMap, + verification: WorkspaceVerification, +} + +enum WorkspaceVerification { + Approved(FreshSourceVerification), + #[cfg(test)] + Fixture, +} + +impl WorkspaceOrigins { + /// Rechecks all approved tracked bytes before and after recipe execution. + /// + /// # Errors + /// Rejects changed source, unexpected files and expired preparation attempts. + pub(crate) fn verify_unchanged(&self) -> Result<(), Report> { + match &self.verification { + WorkspaceVerification::Approved(verification) => verification.verify_unchanged(&[]), + #[cfg(test)] + WorkspaceVerification::Fixture => Ok(()), + } + } + /// Binds approved tracked identities to an unchanged independent checkout. + /// + /// # Errors + /// Rejects a changed checkout, mismatched commit or missing approved lock. + pub(crate) fn bind( + approved: &ApprovedSource, + fresh: &FreshSource, + ) -> Result> { + fresh.verify_unchanged(&[])?; + if approved.commit().as_str() != fresh.commit() { + return Err(composition_error( + "fresh checkout has a different approved commit", + )); + } + real_path(fresh.root())?; + Ok(Self { + root: fresh.root().to_owned(), + source_commit: fresh.commit().to_owned(), + cargo_lock_sha256: approved + .tracked_sha256("Cargo.lock") + .ok_or_else(|| composition_error("approved source has no Cargo.lock"))? + .to_owned(), + manifests: approved.cargo_manifest_digests(), + verification: WorkspaceVerification::Approved(fresh.verification()), + }) + } +} + +/// Origins for compiled packages only; host paths never enter serialized evidence. +#[derive(Serialize)] +pub(crate) struct VerifiedPackageOrigins { + source_commit: String, + materials_sha256: String, + packages: BTreeMap, + #[serde(skip)] + native_sources: BTreeMap, +} + +/// One exact observed package joined to an approved original. +#[derive(Serialize)] +pub(crate) struct PackageOrigin { + name: String, + version: String, + manifest_path: PathBuf, + prepared_manifest_sha256: String, + origin: Original, +} + +/// The approved workspace or verified dependency original behind a package. +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum Original { + /// A tracked package within the approved source commit. + Workspace { + source_commit: String, + path: PathBuf, + }, + /// A registry archive or Git object closure verified by material preparation. + Material { + source: String, + original: CargoOrigin, + }, +} + +impl VerifiedPackageOrigins { + /// Returns the approved source commit bound to these package origins. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } + + /// Returns the measured prepared Cargo materials tree identity. + pub(crate) fn materials_sha256(&self) -> &str { + &self.materials_sha256 + } + + /// Looks up a verified origin by its exact observed Cargo package ID. + pub(crate) fn package(&self, package_id: &str) -> Option<&PackageOrigin> { + self.packages.get(package_id) + } + + /// Registry archives and prepared roots authorized by the exact-ID join. + pub(crate) fn native_sources(&self) -> &BTreeMap { + &self.native_sources + } +} + +impl PackageOrigin { + /// Returns the package name checked against its verified original. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Returns the package version checked against its verified original. + pub(crate) fn version(&self) -> &str { + &self.version + } + + /// Returns the source-relative or prepared vendor-relative manifest path. + pub(crate) fn relative_manifest_path(&self) -> &Path { + &self.manifest_path + } + + /// Returns the measured prepared manifest digest. + pub(crate) fn prepared_manifest_sha256(&self) -> &str { + &self.prepared_manifest_sha256 + } + + /// Returns the verified original without reconstructing it from a package name. + pub(crate) fn origin(&self) -> &Original { + &self.origin + } +} + +/// Joins observed package IDs to exact measured source identities. +/// +/// # Errors +/// Rejects changed materials, unapproved manifests, aliases and mismatched identities. +pub(crate) fn join_origins( + observed: &CargoObservations, + workspace: &WorkspaceOrigins, + materials: &PreparedCargoMaterials, + paths: &dyn ExecutionPaths, +) -> Result> { + workspace.verify_unchanged()?; + if materials.source_commit() != workspace.source_commit + || materials.cargo_lock_sha256() != workspace.cargo_lock_sha256 + { + return Err(composition_error( + "Cargo materials do not belong to the approved source and lock", + )); + } + materials.rehash()?; + real_path(&workspace.root)?; + let mut packages = BTreeMap::new(); + let mut native_sources = BTreeMap::new(); + let mut used_manifests = BTreeSet::new(); + for package in &observed.packages { + super::normal_path(&package.manifest_path)?; + if !used_manifests.insert(package.manifest_path.clone()) { + return Err(composition_error( + "compiled package manifest has ambiguous Cargo identities", + )); + } + let (relative, hash, origin) = if let Some(source) = &package.source { + let mut candidates = Vec::new(); + for (directory, candidate) in materials.packages() { + let host = materials.vendor_dir().join(directory).join("Cargo.toml"); + if paths.execution_path(&host)? == package.manifest_path { + candidates.push((directory, candidate, host)); + } + } + let [(directory, candidate, host)] = candidates.as_slice() else { + return Err(composition_error( + "compiled dependency has no unique prepared manifest", + )); + }; + if candidate.name() != package.name + || candidate.version() != package.version + || candidate.source() != source + { + return Err(composition_error( + "compiled dependency differs from its verified original identity", + )); + } + let manifest = measured_manifest(host, candidate.prepared_manifest_sha256())?; + require_identity(&manifest, &package.name, &package.version, None)?; + if let CargoOrigin::Registry { archive_sha256 } = candidate.origin() { + native_sources.insert( + package.id.clone(), + NativeSource { + original_sha256: archive_sha256.clone(), + source_root: materials.vendor_dir().join(directory), + }, + ); + } + ( + PathBuf::from("vendor").join(directory).join("Cargo.toml"), + candidate.prepared_manifest_sha256().to_owned(), + Original::Material { + source: source.clone(), + original: candidate.origin().clone(), + }, + ) + } else { + let mut candidates = Vec::new(); + for (relative, hash) in &workspace.manifests { + if paths.execution_path(&workspace.root.join(relative))? == package.manifest_path { + candidates.push((relative, hash)); + } + } + let [(relative, hash)] = candidates.as_slice() else { + return Err(composition_error( + "compiled workspace package has no unique approved tracked manifest", + )); + }; + let manifest = measured_manifest(&workspace.root.join(relative), hash)?; + require_identity( + &manifest, + &package.name, + &package.version, + Some((workspace, relative)), + )?; + ( + (*relative).clone(), + (*hash).clone(), + Original::Workspace { + source_commit: workspace.source_commit.clone(), + path: (*relative).clone(), + }, + ) + }; + if packages + .insert( + package.id.clone(), + PackageOrigin { + name: package.name.clone(), + version: package.version.clone(), + manifest_path: relative, + prepared_manifest_sha256: hash, + origin, + }, + ) + .is_some() + { + return Err(composition_error("compiled Cargo ID is duplicated")); + } + } + materials.rehash()?; + workspace.verify_unchanged()?; + Ok(VerifiedPackageOrigins { + source_commit: workspace.source_commit.clone(), + materials_sha256: materials.digest().to_owned(), + packages, + native_sources, + }) +} + +fn real_path(path: &Path) -> Result<(), Report> { + super::normal_path(path)?; + if fs::canonicalize(path).map_err(composition_error)? != path { + return Err(composition_error("origin path contains an alias")); + } + Ok(()) +} + +fn measured_manifest(path: &Path, expected: &str) -> Result> { + real_path(path)?; + let raw = read_bounded(path, Role::Source)?; + real_path(path)?; + if raw.sha256 != expected || raw.bytes.len() > 1024 * 1024 { + return Err(composition_error( + "origin manifest differs from its approved digest", + )); + } + toml::from_str(std::str::from_utf8(&raw.bytes).map_err(composition_error)?) + .map_err(composition_error) +} + +fn require_identity( + manifest: &toml::Table, + name: &str, + version: &str, + workspace: Option<(&WorkspaceOrigins, &Path)>, +) -> Result<(), Report> { + let package = manifest + .get("package") + .and_then(toml::Value::as_table) + .ok_or_else(|| composition_error("origin manifest has no package"))?; + if package.get("name").and_then(toml::Value::as_str) != Some(name) + || package.contains_key("workspace") + { + return Err(composition_error( + "origin package name or workspace selection differs", + )); + } + if package.get("version").and_then(toml::Value::as_str) == Some(version) { + return Ok(()); + } + if package + .get("version") + .and_then(toml::Value::as_table) + .is_none_or(|value| { + value.len() != 1 || value.get("workspace").and_then(toml::Value::as_bool) != Some(true) + }) + { + return Err(composition_error("origin package version differs")); + } + let (workspace, relative) = workspace + .ok_or_else(|| composition_error("prepared manifest has unresolved version inheritance"))?; + let root_relative = Path::new("Cargo.toml"); + let mut parent = relative.parent(); + while let Some(directory) = parent { + let ancestor = directory.join("Cargo.toml"); + if ancestor != relative + && ancestor != root_relative + && let Some(hash) = workspace.manifests.get(&ancestor) + { + let ancestor = measured_manifest(&workspace.root.join(&ancestor), hash)?; + if ancestor.contains_key("workspace") { + return Err(composition_error( + "nested workspace version inheritance is unreviewed", + )); + } + } + parent = directory.parent(); + } + let hash = workspace + .manifests + .get(root_relative) + .ok_or_else(|| composition_error("approved root workspace manifest is missing"))?; + let root = measured_manifest(&workspace.root.join(root_relative), hash)?; + if root + .get("workspace") + .and_then(|value| value.get("package")) + .and_then(|value| value.get("version")) + .and_then(toml::Value::as_str) + != Some(version) + { + return Err(composition_error("approved workspace version differs")); + } + Ok(()) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +impl VerifiedPackageOrigins { + /// Provides fictional registry joins for owner-checked native test fixtures. + pub(crate) fn fixture_native_registry(observed: &CargoObservations) -> Self { + Self { + source_commit: "a".repeat(40), + materials_sha256: "b".repeat(64), + packages: observed + .packages() + .iter() + .map(|package| { + ( + package.id().to_owned(), + PackageOrigin { + name: package.name().to_owned(), + version: package.version().to_owned(), + manifest_path: Path::new("native") + .join(package.name()) + .join("Cargo.toml"), + prepared_manifest_sha256: "b".repeat(64), + origin: Original::Material { + source: "registry+https://example.com/index".to_owned(), + original: crate::materials::CargoOrigin::Registry { + archive_sha256: "a".repeat(64), + }, + }, + }, + ) + }) + .collect(), + native_sources: BTreeMap::new(), + } + } +} diff --git a/crates/trusted-server-release/src/composition/origin/tests.rs b/crates/trusted-server-release/src/composition/origin/tests.rs new file mode 100644 index 000000000..3772ea8d0 --- /dev/null +++ b/crates/trusted-server-release/src/composition/origin/tests.rs @@ -0,0 +1,397 @@ +use std::fs; + +use sha2::{Digest as _, Sha256}; +use tempfile::TempDir; + +use super::*; +use crate::composition::RustPackage; + +struct Paths; +impl ExecutionPaths for Paths { + fn execution_path(&self, host: &Path) -> Result> { + Ok(Path::new("/execution").join(host.strip_prefix("/").expect("should be absolute"))) + } + fn host_path(&self, _: &Path) -> Result> { + panic!("should never reverse-map immutable inputs") + } +} + +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +struct Fixture { + _source: TempDir, + _materials: TempDir, + workspace: WorkspaceOrigins, + materials: PreparedCargoMaterials, + observed: CargoObservations, +} + +impl Fixture { + fn workspace() -> Self { + let source = tempfile::tempdir().expect("should allocate source"); + let root = fs::canonicalize(source.path()).expect("should canonicalize source"); + let manifest = b"[package]\nname='example'\nversion='1.0.0'\n"; + fs::write(root.join("Cargo.toml"), manifest).expect("should write manifest"); + let materials = tempfile::tempdir().expect("should allocate materials"); + let material_root = + fs::canonicalize(materials.path()).expect("should canonicalize materials"); + let token = PreparedCargoMaterials::for_tests(&material_root, &"a".repeat(40), &[]) + .expect("should prepare token"); + Self { + observed: CargoObservations { + packages: vec![RustPackage { + id: "opaque identity".to_owned(), + name: "example".to_owned(), + version: "1.0.0".to_owned(), + source: None, + manifest_path: Paths + .execution_path(&root.join("Cargo.toml")) + .expect("should translate manifest"), + license: None, + }], + units: vec![], + build_scripts: vec![], + }, + workspace: WorkspaceOrigins { + verification: WorkspaceVerification::Fixture, + root, + source_commit: "a".repeat(40), + cargo_lock_sha256: "b".repeat(64), + manifests: BTreeMap::from([(PathBuf::from("Cargo.toml"), digest(manifest))]), + }, + materials: token, + _source: source, + _materials: materials, + } + } + fn join(&self) -> Result> { + join_origins(&self.observed, &self.workspace, &self.materials, &Paths) + } +} + +#[test] +fn workspace_joins_exact_opaque_id_to_approved_measured_manifest() { + let fixture = Fixture::workspace(); + let result = fixture + .join() + .expect("should join tracked workspace package"); + assert!( + result.package("opaque identity").is_some(), + "should retain exact opaque identity" + ); + assert!( + result.package("example").is_none(), + "should refuse name-only lookups for an exact observed identity" + ); + let package = result + .package("opaque identity") + .expect("should retain package origin"); + assert_eq!(package.name(), "example", "should retain checked name"); + assert_eq!(package.version(), "1.0.0", "should retain checked version"); + assert_eq!( + package.relative_manifest_path(), + Path::new("Cargo.toml"), + "should retain relative manifest" + ); + assert_eq!( + package.prepared_manifest_sha256(), + fixture.workspace.manifests[Path::new("Cargo.toml")], + "should retain measured manifest identity" + ); + assert!( + matches!(package.origin(), Original::Workspace { source_commit, path } if source_commit == &"a".repeat(40) && path == Path::new("Cargo.toml")), + "should retain approved original identity" + ); + assert!( + result.native_sources().is_empty(), + "should not authorize workspace as registry archive" + ); +} + +#[test] +fn workspace_rejects_unapproved_identity_path_and_changed_bytes() { + for mutation in [ + "name", + "version", + "path", + "bytes", + "commit", + "lock", + "alias", + "missing", + "untracked", + ] { + let mut fixture = Fixture::workspace(); + match mutation { + "name" => fixture.observed.packages[0].name = "other".to_owned(), + "version" => fixture.observed.packages[0].version = "9.0.0".to_owned(), + "path" => { + fixture.observed.packages[0].manifest_path = PathBuf::from("/unapproved/Cargo.toml") + } + "bytes" => fs::write(fixture.workspace.root.join("Cargo.toml"), b"changed") + .expect("should mutate bytes"), + "commit" => fixture.workspace.source_commit = "c".repeat(40), + "lock" => fixture.workspace.cargo_lock_sha256 = "c".repeat(64), + "alias" => { + fs::rename( + fixture.workspace.root.join("Cargo.toml"), + fixture.workspace.root.join("original"), + ) + .expect("should move original"); + std::os::unix::fs::symlink("original", fixture.workspace.root.join("Cargo.toml")) + .expect("should alias manifest"); + } + "missing" => fs::remove_file(fixture.workspace.root.join("Cargo.toml")) + .expect("should remove approved manifest"), + "untracked" => { + fs::create_dir(fixture.workspace.root.join("untracked")) + .expect("should create untracked dependency"); + fs::copy( + fixture.workspace.root.join("Cargo.toml"), + fixture.workspace.root.join("untracked/Cargo.toml"), + ) + .expect("should write unapproved matching identity"); + fixture.observed.packages[0].manifest_path = Paths + .execution_path(&fixture.workspace.root.join("untracked/Cargo.toml")) + .expect("should translate unapproved dependency"); + } + _ => unreachable!(), + } + assert!( + fixture.join().is_err(), + "should reject {mutation} substitution" + ); + } +} + +#[test] +fn registry_requires_exact_source_path_name_version_and_manifest() { + for mutation in [ + "none", + "name", + "version", + "source", + "path", + "bytes", + "ambiguous", + ] { + let mut fixture = Fixture::workspace(); + let manifest = b"[package]\nname='example'\nversion='1.0.0'\n"; + let root = fixture.materials.vendor_dir().join("example-1.0.0"); + fs::create_dir(&root).expect("should create package root"); + fs::write(root.join("Cargo.toml"), manifest).expect("should write package manifest"); + let token = + PreparedCargoMaterials::for_tests(fixture.materials.root(), &"a".repeat(40), &[]) + .expect("should measure vendor"); + let package = crate::materials::VerifiedCargoPackage::registry_for_tests( + "example", + "1.0.0", + "registry+https://example.com/index", + &digest(manifest), + &"d".repeat(64), + ); + fixture.materials = token.with_package_for_tests("example-1.0.0", package.clone()); + fixture.observed.packages[0].source = Some(package.source().to_owned()); + fixture.observed.packages[0].manifest_path = Paths + .execution_path(&root.join("Cargo.toml")) + .expect("should translate vendor manifest"); + match mutation { + "name" => fixture.observed.packages[0].name = "other".to_owned(), + "version" => fixture.observed.packages[0].version = "9.0.0".to_owned(), + "source" => { + fixture.observed.packages[0].source = + Some("git+https://example.com/repository#abc".to_owned()) + } + "path" => { + fixture.observed.packages[0].manifest_path = fixture.observed.packages[0] + .manifest_path + .with_file_name("Other.toml") + } + "bytes" => { + fs::write(root.join("Cargo.toml"), b"changed").expect("should mutate vendor") + } + "ambiguous" => fixture.observed.packages.push(RustPackage { + id: "different opaque ID".to_owned(), + name: "example".to_owned(), + version: "1.0.0".to_owned(), + source: fixture.observed.packages[0].source.clone(), + manifest_path: fixture.observed.packages[0].manifest_path.clone(), + license: None, + }), + _ => {} + } + let result = fixture.join(); + if mutation == "none" { + let origins = result.expect("should join exact registry package"); + assert_eq!( + origins.native_sources()["opaque identity"].original_sha256, + "d".repeat(64), + "should authorize verified archive only" + ); + let serialized = serde_json::to_string(&origins).expect("should serialize origins"); + assert!( + !serialized.contains(root.to_str().expect("should have UTF-8 fixture root")), + "should exclude host root" + ); + } else { + assert!(result.is_err(), "should reject {mutation} substitution"); + } + } +} + +#[test] +fn root_workspace_version_inheritance_requires_approved_root_bytes() { + for mutation in ["none", "root_bytes", "version", "nested", "redirect"] { + let mut fixture = Fixture::workspace(); + let root = b"[workspace]\nmembers=['member']\n[workspace.package]\nversion='1.0.0'\n"; + let member = if mutation == "redirect" { + b"[package]\nname='example'\nversion.workspace=true\nworkspace='../elsewhere'\n" + .as_slice() + } else { + b"[package]\nname='example'\nversion.workspace=true\n".as_slice() + }; + fs::write(fixture.workspace.root.join("Cargo.toml"), root) + .expect("should write workspace root"); + fs::create_dir(fixture.workspace.root.join("member")).expect("should create member"); + fs::write(fixture.workspace.root.join("member/Cargo.toml"), member) + .expect("should write member"); + fixture.workspace.manifests = BTreeMap::from([ + (PathBuf::from("Cargo.toml"), digest(root)), + (PathBuf::from("member/Cargo.toml"), digest(member)), + ]); + fixture.observed.packages[0].manifest_path = Paths + .execution_path(&fixture.workspace.root.join("member/Cargo.toml")) + .expect("should translate member"); + match mutation { + "root_bytes" => fs::write( + fixture.workspace.root.join("Cargo.toml"), + b"[workspace.package]\nversion='9.0.0'\n", + ) + .expect("should replace root"), + "version" => fixture.observed.packages[0].version = "9.0.0".to_owned(), + "nested" => { + let nested = b"[workspace]\n[workspace.package]\nversion='1.0.0'\n"; + fs::create_dir(fixture.workspace.root.join("member/nested")) + .expect("should create nested member"); + fs::write(fixture.workspace.root.join("member/Cargo.toml"), nested) + .expect("should write nested workspace"); + fixture + .workspace + .manifests + .insert(PathBuf::from("member/Cargo.toml"), digest(nested)); + fs::write( + fixture.workspace.root.join("member/nested/Cargo.toml"), + member, + ) + .expect("should write nested package"); + fixture + .workspace + .manifests + .insert(PathBuf::from("member/nested/Cargo.toml"), digest(member)); + fixture.observed.packages[0].manifest_path = Paths + .execution_path(&fixture.workspace.root.join("member/nested/Cargo.toml")) + .expect("should translate nested member"); + } + _ => {} + } + assert_eq!( + fixture.join().is_ok(), + mutation == "none", + "should enforce reviewed inheritance for {mutation}" + ); + } +} + +#[test] +fn git_join_preserves_exact_revision_trees_and_normalization_without_native_archive_authority() { + for mutation in ["none", "revision", "manifest", "source_null"] { + let mut fixture = Fixture::workspace(); + let manifest = b"[package]\nname='example'\nversion='1.0.0'\n"; + let root = fixture.materials.vendor_dir().join("example"); + fs::create_dir(&root).expect("should create Git package"); + fs::write(root.join("Cargo.toml"), manifest).expect("should write normalized manifest"); + let source = format!( + "git+https://example.com/repository?rev=reviewed#{}", + "e".repeat(40) + ); + let original = CargoOrigin::Git { + revision: "e".repeat(40), + repository_tree: "f".repeat(40), + package_tree: "1".repeat(40), + package_path: "member".to_owned(), + committed_manifest_blob: "2".repeat(40), + committed_manifest_sha256: "3".repeat(64), + normalized_manifest_sha256: Some(digest(manifest)), + }; + let package = crate::materials::VerifiedCargoPackage::registry_for_tests( + "example", + "1.0.0", + &source, + &digest(manifest), + &"d".repeat(64), + ) + .with_origin_for_tests(original); + let unused = package.clone(); + fs::create_dir(fixture.materials.vendor_dir().join("unused")) + .expect("should create unused prepared package"); + fs::write( + fixture.materials.vendor_dir().join("unused/Cargo.toml"), + manifest, + ) + .expect("should write unused prepared manifest"); + fixture.materials = + PreparedCargoMaterials::for_tests(fixture.materials.root(), &"a".repeat(40), &[]) + .expect("should measure Git vendor") + .with_package_for_tests("example", package) + .with_package_for_tests("unused", unused); + fixture.observed.packages[0].manifest_path = Paths + .execution_path(&root.join("Cargo.toml")) + .expect("should translate Git manifest"); + fixture.observed.packages[0].source = Some(source); + match mutation { + "revision" => { + fixture.observed.packages[0].source = Some(format!( + "git+https://example.com/repository?rev=reviewed#{}", + "9".repeat(40) + )) + } + "manifest" => { + fixture.observed.packages[0].manifest_path = Paths + .execution_path(&fixture.workspace.root.join("Cargo.toml")) + .expect("should translate wrong manifest") + } + "source_null" => fixture.observed.packages[0].source = None, + _ => {} + } + if mutation == "none" { + let result = fixture.join().expect("should join exact Git identity"); + assert_eq!( + result.packages.len(), + 1, + "should exclude prepared unused package" + ); + assert!( + result.native_sources().is_empty(), + "should not treat Git tree as original registry archive" + ); + let serialized = serde_json::to_value(result).expect("should serialize exact origin"); + assert_eq!( + serialized["packages"]["opaque identity"]["origin"]["original"]["revision"], + "e".repeat(40), + "should retain exact revision" + ); + assert_eq!( + serialized["packages"]["opaque identity"]["origin"]["original"]["normalized_manifest_sha256"], + digest(manifest), + "should retain normalization digest" + ); + } else { + assert!( + fixture.join().is_err(), + "should reject {mutation} substitution" + ); + } + } +} diff --git a/crates/trusted-server-release/src/composition/platform.rs b/crates/trusted-server-release/src/composition/platform.rs new file mode 100644 index 000000000..caddbe5f7 --- /dev/null +++ b/crates/trusted-server-release/src/composition/platform.rs @@ -0,0 +1,208 @@ +//! Candidate installed-tool identities and artifact-bound external requirements. + +use error_stack::Report; +use serde::Serialize; + +use super::composition_error; +use super::external::ExternalRequirements; +use crate::documents::lower_hex; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; +use crate::recipe::{AppleDeveloperTools, AppleToolEvidence, RustToolEvidence, SelectedRustTools}; + +/// Candidate platform observations without original acquisition or qualification. +#[derive(Debug, Serialize)] +pub(crate) struct PlatformEvidence { + target: &'static str, + artifact_sha256: String, + rust: RustToolEvidence, + apple: Option, + external_requirements: ExternalRequirements, + native_tools: NativeToolCoverage, + material_native_coverage_complete: bool, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "scope", rename_all = "snake_case")] +enum NativeToolCoverage { + ExplicitFastlyHostFiles { + evidence: Box, + }, + HostPathUnresolved { + roles: [&'static str; 5], + }, + ExplicitAppleFiles { + full_closure_measured: bool, + }, + ExplicitGnuImage { + evidence: Box, + }, +} + +impl PlatformEvidence { + /// Returns the selected target triple. + pub(crate) fn target(&self) -> &str { + self.target + } + /// Returns the accepted executable's raw-byte digest. + pub(crate) fn artifact_sha256(&self) -> &str { + &self.artifact_sha256 + } + /// Borrows the measured installed Rust identity. + pub(crate) fn rust(&self) -> &RustToolEvidence { + &self.rust + } + /// Borrows exact Apple observations, including their exclusions. + pub(crate) fn apple(&self) -> Option<&AppleToolEvidence> { + self.apple.as_ref() + } + /// Borrows the owning GNU measurement, when actually captured. + pub(crate) fn gnu(&self) -> Option<&crate::recipe::gnu::GnuEvidence> { + match &self.native_tools { + NativeToolCoverage::ExplicitGnuImage { evidence } => Some(evidence.as_ref()), + _ => None, + } + } + /// Borrows bounded selected Fastly host files when measured by execution. + pub(crate) fn fastly_tools(&self) -> Option<&crate::recipe::fastly_tools::FastlyToolEvidence> { + match &self.native_tools { + NativeToolCoverage::ExplicitFastlyHostFiles { evidence } => Some(evidence), + _ => None, + } + } + /// Attaches measured selected Linux host files to the Fastly target. + /// + /// # Errors + /// + /// Rejects attachment to another target. + pub(crate) fn with_fastly_tools( + mut self, + evidence: crate::recipe::fastly_tools::FastlyToolEvidence, + ) -> Result> { + if self.target != ReleaseTarget::Wasm32Wasip1.triple() { + return Err(composition_error( + "Fastly host tools belong to another target", + )); + } + self.native_tools = NativeToolCoverage::ExplicitFastlyHostFiles { + evidence: Box::new(evidence), + }; + Ok(self) + } + /// Borrows the accepted executable's logical runtime requirements. + pub(crate) fn external_requirements(&self) -> &ExternalRequirements { + &self.external_requirements + } + /// Returns native tool roles whose host paths were never resolved or measured. + pub(crate) fn unresolved_native_tool_roles(&self) -> &[&'static str] { + match &self.native_tools { + NativeToolCoverage::HostPathUnresolved { roles } => roles, + NativeToolCoverage::ExplicitFastlyHostFiles { .. } => &["assembler", "build_tools"], + NativeToolCoverage::ExplicitAppleFiles { .. } + | NativeToolCoverage::ExplicitGnuImage { .. } => &[], + } + } + /// Returns whether explicit Apple files lack a measured full tool closure. + pub(crate) fn apple_tool_closure_unmeasured(&self) -> bool { + matches!( + self.native_tools, + NativeToolCoverage::ExplicitAppleFiles { + full_closure_measured: false + } + ) + } + /// Returns whether material native coverage is recorded as complete. + pub(crate) fn material_native_coverage_complete(&self) -> bool { + self.material_native_coverage_complete + } + /// Attaches the owning GNU execution's measured installed-image evidence. + /// + /// # Errors + /// + /// Rejects attachment to another target; measurement constructors remain + /// private to the actual denied recipe execution. + pub(crate) fn with_gnu( + mut self, + evidence: crate::recipe::gnu::GnuEvidence, + ) -> Result> { + if self.target != ReleaseTarget::X86_64UnknownLinuxGnu.triple() { + return Err(composition_error( + "GNU image evidence belongs to another target", + )); + } + self.native_tools = NativeToolCoverage::ExplicitGnuImage { + evidence: Box::new(evidence), + }; + Ok(self) + } + /// Binds measured selected tools to requirements from the accepted artifact. + /// + /// # Errors + /// + /// Rejects mismatched artifact/target identities or changed bound tools. + pub(crate) fn collect( + tools: &SelectedRustTools, + bound_tools_digest: &str, + requirements: ExternalRequirements, + target: ReleaseTarget, + artifact_sha256: &str, + ) -> Result> { + if !lower_hex(bound_tools_digest, 64) + || !lower_hex(artifact_sha256, 64) + || tools.target() != target + || requirements.target() != target + || requirements.artifact_sha256() != artifact_sha256 + || tools.apple().is_some() != target.is_darwin() + { + return Err(composition_error( + "candidate platform identities differ from selected inputs", + )); + } + if let Some(apple) = tools.apple() + && requirements.macho_sdk() != Some(apple.sdk_version().to_string().as_str()) + { + return Err(composition_error( + "artifact SDK differs from selected Apple SDK", + )); + } + let rust = tools.candidate_measurement()?; + if rust.tree_sha256() != bound_tools_digest { + return Err(composition_error( + "Rust tools changed before platform measurement", + )); + } + // The Apple evidence is copied from the recorded facts after one + // verification inside `candidate_measurement`; nothing is read from + // disk afterwards, so a second Apple pass here would rehash the same + // SDK and toolchain without changing what the evidence states. + let apple = tools + .apple() + .map(AppleDeveloperTools::candidate_measurement) + .transpose()?; + if tools.digest()? != bound_tools_digest { + return Err(composition_error( + "Rust tools changed during platform measurement", + )); + } + Ok(Self { + target: target.triple(), + artifact_sha256: artifact_sha256.to_owned(), + rust, + apple, + external_requirements: requirements, + native_tools: if target.is_darwin() { + NativeToolCoverage::ExplicitAppleFiles { + full_closure_measured: false, + } + } else { + NativeToolCoverage::HostPathUnresolved { + roles: ["compiler", "assembler", "archiver", "linker", "build_tools"], + } + }, + material_native_coverage_complete: false, + }) + } +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/trusted-server-release/src/composition/platform/tests.rs b/crates/trusted-server-release/src/composition/platform/tests.rs new file mode 100644 index 000000000..60e8dfdc2 --- /dev/null +++ b/crates/trusted-server-release/src/composition/platform/tests.rs @@ -0,0 +1,397 @@ +use std::fs; +use std::path::PathBuf; + +use sha2::{Digest as _, Sha256}; +use tempfile::TempDir; + +use super::*; +use crate::inspect::tests::builders::{Command, ElfSpec, MachoSpec, elf, macho, wasm_module}; +use crate::inspect::{ + ElfPolicy, MachoCpu, MachoPolicy, MacosVersion, inspect_elf, inspect_macho, inspect_wasm, +}; +use crate::recipe::{AppleDeveloperTools, AppleToolFacts}; + +struct Fixture { + _directory: TempDir, + root: PathBuf, + target: ReleaseTarget, +} + +impl Fixture { + fn new(target: ReleaseTarget) -> Self { + let directory = tempfile::tempdir().expect("should allocate tool fixture"); + let root = fs::canonicalize(directory.path()).expect("should canonicalize fixture"); + let standard = root + .join("tools/lib/rustlib") + .join(target.triple()) + .join("lib"); + fs::create_dir_all(&standard).expect("should create selected target sysroot"); + fs::create_dir_all(root.join("tools/bin")).expect("should create tool directory"); + for path in [ + root.join("tools/bin/cargo"), + root.join("tools/bin/rustc"), + standard.join("libstd-example.rlib"), + ] { + fs::write(path, b"fictional measured Rust input").expect("should write Rust fixture"); + } + Self { + _directory: directory, + root, + target, + } + } + + fn tools(&self) -> SelectedRustTools { + SelectedRustTools::for_tests(&self.root.join("tools"), self.target) + .expect("should select measured candidate tools") + } + + fn apple(&self) -> AppleDeveloperTools { + self.apple_with_detached(false) + } + + fn apple_with_detached(&self, detached: bool) -> AppleDeveloperTools { + let developer_dir = self.root.join("developer"); + let bin = developer_dir.join("Toolchains/XcodeDefault.xctoolchain/usr/bin"); + let sdk_root = + developer_dir.join("Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk"); + fs::create_dir_all(&bin).expect("should create Apple tools"); + fs::create_dir_all(&sdk_root).expect("should create SDK settings directory"); + for path in [ + bin.join("clang"), + bin.join("ar"), + bin.join("ld"), + sdk_root.join("SDKSettings.json"), + ] { + fs::write(path, b"fictional measured Apple input").expect("should write Apple fixture"); + } + let digest = hex::encode(Sha256::digest(b"fictional measured Apple input")); + let facts = AppleToolFacts { + developer_dir, + sdk_root, + clang: bin.join("clang"), + ar: bin.join("ar"), + ld: bin.join("ld"), + xcode_version: "16.4".to_owned(), + sdk_version: MacosVersion::new(15, 5), + clang_sha256: digest.clone(), + ar_sha256: digest.clone(), + ld_sha256: digest.clone(), + sdk_settings_sha256: digest, + }; + if detached { + AppleDeveloperTools::with_detached_sdk_for_tests( + facts, + std::time::Instant::now() + std::time::Duration::from_secs(30), + &crate::process::Cancellation::default(), + ) + .expect("should stage fictional detached SDK") + } else { + AppleDeveloperTools::from_facts_for_tests(facts) + .expect("should select measured Apple candidate") + } + } +} + +fn requirements(target: ReleaseTarget) -> ExternalRequirements { + match target { + ReleaseTarget::Wasm32Wasip1 => ExternalRequirements::from_wasm( + &inspect_wasm(&wasm_module(&[("fastly_http_req", "downstream_get")])) + .expect("should accept fixture Wasm"), + ), + ReleaseTarget::X86_64UnknownLinuxGnu => ExternalRequirements::from_elf( + &inspect_elf(&elf(&ElfSpec::conforming()), &ElfPolicy::linux_x86_64_gnu()) + .expect("should accept fixture ELF"), + ), + ReleaseTarget::Aarch64AppleDarwin | ReleaseTarget::X86_64AppleDarwin => { + let cpu = if target == ReleaseTarget::Aarch64AppleDarwin { + MachoCpu::Arm64 + } else { + MachoCpu::X86_64 + }; + let mut spec = MachoSpec::conforming(); + if target == ReleaseTarget::X86_64AppleDarwin { + spec.cputype = object::macho::CPU_TYPE_X86_64; + spec.cpusubtype = object::macho::CPU_SUBTYPE_X86_64_ALL; + } + ExternalRequirements::from_macho( + &inspect_macho(&macho(&spec), &MachoPolicy::macos(cpu)) + .expect("should accept fixture Mach-O"), + ) + } + } +} + +fn collect( + fixture: &Fixture, + tools: &SelectedRustTools, +) -> Result> { + let external = requirements(fixture.target); + let artifact = external.artifact_sha256().to_owned(); + PlatformEvidence::collect(tools, &tools.digest()?, external, fixture.target, &artifact) +} + +/// Constructs typed platform fixtures only by measuring fictional tool files. +pub(crate) fn projection_fixture(target: ReleaseTarget) -> PlatformEvidence { + let fixture = Fixture::new(target); + let tools = if target.is_darwin() { + fixture.tools().with_apple_developer_tools(fixture.apple()) + } else { + fixture.tools() + }; + collect(&fixture, &tools).expect("should collect measured platform fixture") +} + +/// Measures an actual detached fictional SDK for the platform adapter tests. +pub(crate) fn projection_detached_sdk_fixture() -> PlatformEvidence { + let fixture = Fixture::new(ReleaseTarget::Aarch64AppleDarwin); + let tools = fixture + .tools() + .with_apple_developer_tools(fixture.apple_with_detached(true)); + collect(&fixture, &tools).expect("should collect detached SDK fixture") +} + +/// Measures a detached SDK and an in-place fictional toolchain. +pub(crate) fn projection_measured_toolchain_fixture() -> PlatformEvidence { + projection_toolchain_with_libtapi_fixture(false) +} + +/// Measures a fictional toolchain with an explicitly selected optional TAPI file. +pub(crate) fn projection_toolchain_with_libtapi_fixture(libtapi: bool) -> PlatformEvidence { + let fixture = Fixture::new(ReleaseTarget::Aarch64AppleDarwin); + let apple = fixture.apple_with_detached(true); + let usr = fixture + .root + .join("developer/Toolchains/XcodeDefault.xctoolchain/usr"); + fs::create_dir_all(usr.join("lib/clang/17/include")) + .expect("should create fictional resource directory"); + fs::write(usr.join("lib/libLTO.dylib"), b"fictional libLTO") + .expect("should write fictional libLTO"); + if libtapi { + fs::write(usr.join("lib/libtapi.dylib"), b"fictional libtapi") + .expect("should write fictional libtapi"); + } + fs::write( + usr.join("lib/clang/17/include/stddef.h"), + b"fictional header", + ) + .expect("should write fictional resource header"); + let apple = apple + .with_measured_toolchain_for_tests( + std::time::Instant::now() + std::time::Duration::from_secs(30), + &crate::process::Cancellation::default(), + ) + .expect("should measure fictional toolchain"); + let tools = fixture.tools().with_apple_developer_tools(apple); + collect(&fixture, &tools).expect("should collect measured toolchain fixture") +} + +#[test] +fn candidate_platform_receipt_binds_actual_tools_and_external_artifact_without_completing_coverage() +{ + let fixture = Fixture::new(ReleaseTarget::X86_64UnknownLinuxGnu); + let tools = fixture.tools(); + let receipt = collect(&fixture, &tools).expect("should bind candidate platform evidence"); + let value = serde_json::to_value(receipt).expect("should serialize platform receipt"); + assert_eq!( + value["rust"]["tree_sha256"], + tools.digest().expect("should measure tree"), + "should bind actual selected tool tree" + ); + assert_eq!( + value["target"], "x86_64-unknown-linux-gnu", + "should preserve selected target" + ); + assert_eq!( + value["artifact_sha256"], value["external_requirements"]["artifact_sha256"], + "should bind requirements to inspected executable" + ); + assert_eq!( + value["material_native_coverage_complete"], false, + "should retain incomplete coverage gate" + ); + assert_eq!( + value["native_tools"]["scope"], "host_path_unresolved", + "should not invent identities for Linux host PATH tools" + ); + assert_eq!( + value["native_tools"]["roles"], + serde_json::json!(["compiler", "assembler", "archiver", "linker", "build_tools"]), + "should retain every unresolved native tool role" + ); + assert_eq!( + value["rust"]["qualification"], "unqualified", + "should preserve candidate measurement authority" + ); +} + +#[test] +fn platform_binding_rejects_tool_target_requirement_target_artifact_and_digest_mismatches() { + for mismatch in [ + "tool_target", + "requirements_target", + "artifact", + "bound_digest", + ] { + let fixture = Fixture::new(ReleaseTarget::Wasm32Wasip1); + let tools = fixture.tools(); + let external = requirements( + if matches!(mismatch, "tool_target" | "requirements_target") { + ReleaseTarget::X86_64UnknownLinuxGnu + } else { + fixture.target + }, + ); + let artifact = if mismatch == "artifact" { + "a".repeat(64) + } else { + external.artifact_sha256().to_owned() + }; + let bound = if mismatch == "bound_digest" { + "b".repeat(64) + } else { + tools.digest().expect("should measure bound tools") + }; + let target = if mismatch == "tool_target" { + ReleaseTarget::X86_64UnknownLinuxGnu + } else { + fixture.target + }; + assert!( + PlatformEvidence::collect(&tools, &bound, external, target, &artifact).is_err(), + "should reject {mismatch}" + ); + } +} + +#[test] +fn platform_binding_rejects_rust_compiler_and_sysroot_changes_after_binding() { + for relative in [ + "bin/rustc", + "lib/rustlib/wasm32-wasip1/lib/libstd-example.rlib", + ] { + let fixture = Fixture::new(ReleaseTarget::Wasm32Wasip1); + let tools = fixture.tools(); + let bound = tools + .digest() + .expect("should measure tools before mutation"); + fs::write( + fixture.root.join("tools").join(relative), + b"changed selected tool input", + ) + .expect("should change tool input"); + let external = requirements(fixture.target); + let artifact = external.artifact_sha256().to_owned(); + assert!( + PlatformEvidence::collect(&tools, &bound, external, fixture.target, &artifact).is_err(), + "should reject changed {relative}" + ); + } +} + +#[test] +fn darwin_platform_receipt_requires_apple_files_and_retains_sdk_coverage_exclusions() { + let fixture = Fixture::new(ReleaseTarget::Aarch64AppleDarwin); + assert!( + collect(&fixture, &fixture.tools()).is_err(), + "should require selected Apple tools for Darwin" + ); + let tools = fixture.tools().with_apple_developer_tools(fixture.apple()); + let receipt = collect(&fixture, &tools).expect("should retain measured Apple files"); + let value = serde_json::to_value(receipt).expect("should serialize Apple receipt"); + assert_eq!( + value["apple"]["coverage_exclusions"], + serde_json::json!([ + "sdk_headers", + "sdk_stubs", + "sdk_frameworks", + "tool_helpers", + "liblto", + "clang_resource_directory", + "toolchain_libraries" + ]), + "should never imply complete SDK coverage" + ); + assert_eq!( + value["native_tools"]["full_closure_measured"], false, + "should retain incomplete native tool closure" + ); + fs::write( + tools.apple().expect("should have Apple selection").clang(), + b"changed Apple compiler", + ) + .expect("should change selected Apple tool"); + assert!( + collect(&fixture, &tools).is_err(), + "should reject changed Apple files" + ); +} + +#[test] +fn non_darwin_platform_receipt_rejects_apple_selection() { + let fixture = Fixture::new(ReleaseTarget::Wasm32Wasip1); + let tools = fixture.tools().with_apple_developer_tools(fixture.apple()); + assert!( + collect(&fixture, &tools).is_err(), + "should reject Apple selection for a non-Darwin target" + ); +} + +#[test] +fn platform_binding_rejects_an_artifact_built_for_another_apple_sdk() { + let fixture = Fixture::new(ReleaseTarget::Aarch64AppleDarwin); + let tools = fixture.tools().with_apple_developer_tools(fixture.apple()); + let mut spec = MachoSpec::conforming(); + for command in &mut spec.commands { + if let Command::BuildVersion { platform, minos } = *command { + *command = Command::BuildVersionSdk { + platform, + minos, + sdk: (15 << 16) | (4 << 8), + }; + } + } + let facts = inspect_macho(&macho(&spec), &MachoPolicy::macos(MachoCpu::Arm64)) + .expect("should inspect an otherwise accepted executable with another SDK"); + let external = ExternalRequirements::from_macho(&facts); + assert!( + PlatformEvidence::collect( + &tools, + &tools.digest().expect("should measure bound tools"), + external, + fixture.target, + facts.sha256(), + ) + .is_err(), + "should reject a recorded SDK differing from selected Apple tools" + ); +} + +#[test] +fn fastly_tool_attachment_preserves_target_and_receipt_incompleteness() { + let platform = projection_fixture(ReleaseTarget::Wasm32Wasip1) + .with_fastly_tools(crate::recipe::fastly_tools::FastlyToolEvidence::projection_fixture()) + .expect("should attach selected Fastly host files"); + assert_eq!( + platform.unresolved_native_tool_roles(), + ["assembler", "build_tools"], + "should retain unmeasured closure roles" + ); + assert!( + !platform.material_native_coverage_complete(), + "should retain incomplete native material coverage" + ); + let receipt = serde_json::to_value(&platform).expect("should serialize receipt"); + assert_eq!( + receipt["native_tools"]["evidence"]["full_closure_measured"], false, + "should preserve bounded measurement scope" + ); + assert!( + projection_fixture(ReleaseTarget::X86_64UnknownLinuxGnu) + .with_fastly_tools( + crate::recipe::fastly_tools::FastlyToolEvidence::projection_fixture() + ) + .is_err(), + "should reject another target" + ); +} diff --git a/crates/trusted-server-release/src/composition/tests.rs b/crates/trusted-server-release/src/composition/tests.rs new file mode 100644 index 000000000..94ebcf71a --- /dev/null +++ b/crates/trusted-server-release/src/composition/tests.rs @@ -0,0 +1,491 @@ +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use super::*; +use crate::policy::ReleaseTarget; + +const PACKAGE: &str = "registry+https://example.com/index#example@1.0.0"; +const OTHER: &str = "git+https://example.com/source#example@1.0.0"; + +fn layout() -> CargoLayout { + CargoLayout::new( + ReleaseTarget::Wasm32Wasip1, + PathBuf::from("/build/target"), + PathBuf::from("/build/source"), + ) + .expect("should accept the explicit build layout") +} + +fn target(kind: &str) -> Value { + json!({ + "name": "example", "kind": [kind], "crate_types": [kind], + "src_path": "/build/vendor/example/src/lib.rs" + }) +} + +fn metadata() -> Value { + let package = |id: &str| { + json!({ + "id": id, "name": "example", "version": "1.0.0", + "source": if id == OTHER { "git+https://example.com/source" } else { "registry+https://example.com/index" }, + "manifest_path": "/build/vendor/example/Cargo.toml", + "license": "MIT", "targets": [target("lib"), target("custom-build"), target("proc-macro")] + }) + }; + json!({ + "version": 1, "workspace_root": "/build/source", "target_directory": "/build/target", + "packages": [package(PACKAGE), package(OTHER)], + "resolve": {"nodes": [ + {"id": PACKAGE, "features": ["host", "target", "unused-union-feature"], "dependencies": []}, + {"id": OTHER, "features": [], "dependencies": []} + ]} + }) +} + +fn unit(id: &str, kind: &str, host: bool, features: &[&str]) -> Value { + let prefix = if host { + "/build/target/release" + } else { + "/build/target/wasm32-wasip1/release" + }; + json!({ + "reason": "compiler-artifact", "package_id": id, + "manifest_path": "/build/vendor/example/Cargo.toml", "target": target(kind), + "profile": {"test": false, "opt_level": "3", "debuginfo": 1}, + "features": features, "filenames": [format!("{prefix}/deps/example.rlib")], + "executable": null, "fresh": false + }) +} + +fn stream(messages: &[Value]) -> Vec { + let mut bytes = Vec::new(); + for message in messages + .iter() + .chain([&json!({"reason": "build-finished", "success": true})]) + { + bytes.extend(serde_json::to_vec(message).expect("should encode a message")); + bytes.push(b'\n'); + } + bytes +} + +fn observe(meta: &Value, messages: &[Value]) -> CargoObservations { + observe_cargo( + &serde_json::to_vec(meta).expect("should encode metadata"), + &stream(messages), + &layout(), + ) + .expect("should join the observed compilation units") +} + +#[test] +fn host_and_target_units_keep_observed_features_instead_of_metadata_union() { + let observed = observe( + &metadata(), + &[ + unit(PACKAGE, "lib", true, &["host"]), + unit(PACKAGE, "lib", false, &["target"]), + ], + ); + assert_eq!( + observed.units().len(), + 2, + "should retain both compilation contexts" + ); + let features: Vec<_> = observed + .units() + .iter() + .map(|unit| unit.features().to_vec()) + .collect(); + assert!( + features.contains(&vec!["host".to_owned()]), + "should retain host features" + ); + assert!( + features.contains(&vec!["target".to_owned()]), + "should retain target features" + ); + assert!( + observed + .units() + .iter() + .all(|unit| !unit.features().contains(&"unused-union-feature".to_owned())), + "should not substitute package-union features" + ); + assert_eq!( + observed.packages().len(), + 1, + "should exclude uncompiled metadata packages" + ); +} + +#[test] +fn identical_names_and_versions_from_distinct_sources_remain_separate() { + let observed = observe( + &metadata(), + &[ + unit(PACKAGE, "lib", false, &[]), + unit(OTHER, "lib", false, &[]), + ], + ); + assert_eq!( + observed.packages.len(), + 2, + "should join by exact opaque package ID" + ); +} + +#[test] +fn ambiguous_or_missing_package_and_target_joins_are_rejected() { + let cases = [ + "missing-package", + "duplicate-package", + "manifest", + "target", + "workspace", + "target-directory", + ]; + for case in cases { + let mut meta = metadata(); + let mut message = unit(PACKAGE, "lib", false, &[]); + match case { + "missing-package" => message["package_id"] = json!("missing"), + "duplicate-package" => { + let duplicate = meta["packages"][0].clone(); + meta["packages"] + .as_array_mut() + .expect("should hold packages") + .push(duplicate); + } + "manifest" => message["manifest_path"] = json!("/build/other/Cargo.toml"), + "target" => message["target"]["src_path"] = json!("/build/other/lib.rs"), + "workspace" => meta["workspace_root"] = json!("/another/source"), + "target-directory" => meta["target_directory"] = json!("/another/target"), + _ => unreachable!("should enumerate each case"), + } + assert!( + observe_cargo( + &serde_json::to_vec(&meta).expect("should encode metadata"), + &stream(&[message]), + &layout() + ) + .is_err(), + "should reject {case}" + ); + } +} + +#[test] +fn invalid_unit_context_freshness_and_features_are_rejected() { + for case in [ + "outside", + "mixed", + "proc-macro-target", + "cached", + "test", + "duplicate-feature", + "duplicate-unit", + ] { + let mut message = unit(PACKAGE, "lib", false, &[]); + match case { + "outside" => message["filenames"] = json!(["/elsewhere/example.rlib"]), + "mixed" => { + message["filenames"] = json!([ + "/build/target/release/deps/a.rlib", + "/build/target/wasm32-wasip1/release/deps/a.rlib" + ]) + } + "proc-macro-target" => message["target"] = target("proc-macro"), + "cached" => message["fresh"] = json!(true), + "test" => message["profile"]["test"] = json!(true), + "duplicate-feature" => message["features"] = json!(["same", "same"]), + "duplicate-unit" => {} + _ => unreachable!("should enumerate each case"), + } + let messages = if case == "duplicate-unit" { + vec![message.clone(), message] + } else { + vec![message] + }; + assert!( + observe_cargo( + &serde_json::to_vec(&metadata()).expect("should encode metadata"), + &stream(&messages), + &layout() + ) + .is_err(), + "should reject {case}" + ); + } +} + +#[test] +fn build_script_execution_keeps_link_and_context_observations_separate() { + let script = json!({ + "reason": "build-script-executed", "package_id": PACKAGE, + "out_dir": "/build/target/wasm32-wasip1/release/build/example-123/out", + "linked_libs": ["static=example"], "linked_paths": ["native=/build/target/wasm32-wasip1/release/build/example-123/out"], + "cfgs": ["example"], "env": [["EXAMPLE", "observed"]] + }); + let observed = observe( + &metadata(), + &[unit(PACKAGE, "custom-build", true, &["host"]), script], + ); + assert_eq!( + observed.build_scripts.len(), + 1, + "should retain the execution record" + ); + assert_eq!(observed.build_scripts[0].context, UnitContext::Target); + assert_eq!(observed.build_scripts[0].linked_libs, ["static=example"]); + assert_eq!(observed.units[0].context, UnitContext::Host); +} + +#[test] +fn duplicate_json_and_failed_or_unterminated_streams_are_rejected() { + let meta = serde_json::to_vec(&metadata()).expect("should encode metadata"); + for bytes in [ + b"{\"reason\":\"build-finished\",\"success\":false}\n".as_slice(), + b"{\"reason\":\"build-finished\",\"reason\":\"build-finished\",\"success\":true}\n", + b"{\"reason\":\"build-finished\",\"success\":true}", + ] { + assert!( + observe_cargo(&meta, bytes, &layout()).is_err(), + "should reject invalid stream" + ); + } + assert!( + observe_cargo(b"{\"version\":1,\"version\":1}", &stream(&[]), &layout()).is_err(), + "should reject duplicate metadata keys before insertion" + ); +} + +#[test] +fn retained_raw_and_normalized_observations_are_rehashed_before_handoff() { + let meta = serde_json::to_vec(&metadata()).expect("should encode metadata"); + let capture = CargoCapture::new( + meta, + b"0example v1.0.0\n".to_vec(), + &stream(&[unit(PACKAGE, "lib", false, &[])]), + &layout(), + ["a".repeat(64), "b".repeat(64)], + ) + .expect("should capture checked observations"); + let retained = tempfile::tempdir().expect("should create export storage"); + capture + .retain(retained.path()) + .expect("should retain the observations"); + assert!( + capture.export(retained.path()).is_err(), + "should not export summary-only observations as actual composition receipts" + ); + assert!( + capture.view(retained.path()).is_err(), + "should not expose a typed owner view without sealed receipts" + ); + for name in [ + "cargo-metadata.json", + "cargo-tree.txt", + "cargo-observations.json", + ] { + let directory = tempfile::tempdir().expect("should create retained storage"); + capture + .retain(directory.path()) + .expect("should retain all observations"); + capture + .rehash(directory.path()) + .expect("should rehash the retained observations"); + std::fs::write(directory.path().join(name), b"changed") + .expect("should alter one observation"); + assert!( + capture.rehash(directory.path()).is_err(), + "should reject changed {name}" + ); + let error = match capture.view(directory.path()) { + Ok(_) => panic!("should refuse a typed owner view after changing {name}"), + Err(error) => error, + }; + assert!( + format!("{error:?}").contains(&format!("retained {name} changed")), + "should rehash before checking receipt availability: {error:?}" + ); + } + assert_eq!( + capture.summary()["material_native_coverage_complete"], + false + ); + assert!( + capture + .require_binary( + std::path::Path::new("/build/vendor/example/Cargo.toml"), + "example", + std::path::Path::new("/build/target/wasm32-wasip1/release/example") + ) + .is_err(), + "should not substitute a library for the accepted executable" + ); +} + +#[test] +fn actual_cargo_195_observations_join_after_only_root_path_relocation() { + let metadata = include_bytes!("../../tests/fixtures/composition/cargo-1.95-metadata.json"); + let messages = include_bytes!("../../tests/fixtures/composition/cargo-1.95-messages.jsonl"); + let observed = observe_cargo(metadata, messages, &layout()) + .expect("should parse actual Cargo 1.95 compilation and metadata shapes"); + assert_eq!( + observed.units.len(), + 2, + "should retain the build script and selected binary" + ); + assert_eq!( + observed.build_scripts.len(), + 1, + "should retain actual build-script execution" + ); + assert_eq!( + observed.packages.len(), + 1, + "should exclude uncompiled workspace members" + ); + let captured = CargoCapture::new( + metadata.to_vec(), + b"0control v0.1.0\n".to_vec(), + messages, + &layout(), + ["a".repeat(64), "b".repeat(64)], + ) + .expect("should bind actual observed membership"); + let selected = captured + .require_binary( + std::path::Path::new("/build/source/crates/control/Cargo.toml"), + "control", + std::path::Path::new("/build/target/wasm32-wasip1/release/control.wasm"), + ) + .expect("should name the exact selected target binary"); + assert_eq!( + selected, observed.units[0].package_id, + "should return the exact observed package identity for graph selection" + ); +} + +#[test] +fn duplicate_metadata_targets_and_uncompiled_or_duplicate_scripts_are_rejected() { + let mut meta = metadata(); + let duplicate = meta["packages"][0]["targets"][0].clone(); + meta["packages"][0]["targets"] + .as_array_mut() + .expect("should hold targets") + .push(duplicate); + assert!( + observe_cargo( + &serde_json::to_vec(&meta).expect("should encode metadata"), + &stream(&[unit(PACKAGE, "lib", false, &[])]), + &layout() + ) + .is_err(), + "should reject ambiguous metadata targets" + ); + let script = json!({"reason": "build-script-executed", "package_id": PACKAGE, + "out_dir": "/build/target/wasm32-wasip1/release/build/example-123/out", + "linked_libs": [], "linked_paths": [], "cfgs": [], "env": []}); + let meta = serde_json::to_vec(&metadata()).expect("should encode metadata"); + assert!( + observe_cargo(&meta, &stream(std::slice::from_ref(&script)), &layout()).is_err(), + "should reject a script with no compiled host build-script unit" + ); + assert!( + observe_cargo( + &meta, + &stream(&[ + unit(PACKAGE, "custom-build", true, &[]), + script.clone(), + script + ]), + &layout() + ) + .is_err(), + "should reject duplicate execution observations" + ); + assert!( + observe_cargo( + &meta, + &stream(&[unit(PACKAGE, "custom-build", false, &[])]), + &layout() + ) + .is_err(), + "should reject a build-script compiler output in the target context" + ); +} + +#[test] +fn actual_cross_build_needs_unfiltered_identities_for_host_only_dependencies() { + let metadata = + include_bytes!("../../tests/fixtures/composition/cargo-1.95-cross-host-metadata.json"); + let filtered = include_bytes!( + "../../tests/fixtures/composition/cargo-1.95-cross-target-filtered-metadata.json" + ); + let messages = + include_bytes!("../../tests/fixtures/composition/cargo-1.95-cross-messages.jsonl"); + let rejected = observe_cargo(filtered, messages, &layout()).expect_err( + "should reproduce target-filtered metadata omitting a compiled host dependency", + ); + assert!( + format!("{rejected:?}").contains("example-host-unix"), + "should identify the missing compiled dependency" + ); + let observed = observe_cargo(metadata, messages, &layout()) + .expect("should join actual cross-build units to the complete identity catalog"); + let host = observed + .packages + .iter() + .find(|package| package.name == "example-host-unix") + .expect("should retain observed host-only dependency"); + assert!( + observed + .units + .iter() + .any(|unit| unit.package_id == host.id && unit.context == UnitContext::Host), + "should classify host-only dependency from actual compiler output" + ); + assert_eq!( + observed.packages.len(), + 3, + "should record the three actually compiled packages" + ); + assert_eq!( + observed.units.len(), + 4, + "should retain distinct app host build-script and target binary units" + ); + let tree = include_bytes!("../../tests/fixtures/composition/cargo-1.95-cross-tree.txt"); + let captured = CargoCapture::new( + metadata.to_vec(), + tree.to_vec(), + messages, + &layout(), + ["a".repeat(64), "b".repeat(64)], + ) + .expect("should retain the catalog with actual explicit-target tree and build observations"); + captured + .require_binary( + Path::new("/build/source/app/Cargo.toml"), + "example-app", + Path::new("/build/target/wasm32-wasip1/release/example-app.wasm"), + ) + .expect("should bind the exact selected cross-build executable"); +} + +#[test] +fn internal_metadata_and_stream_observation_bounds_are_enforced() { + let oversized = vec![b' '; 16 * 1024 * 1024 + 1]; + let meta = serde_json::to_vec(&metadata()).expect("should encode metadata"); + assert!( + observe_cargo(&oversized, &stream(&[]), &layout()).is_err(), + "should bound raw metadata" + ); + assert!( + observe_cargo(&meta, &oversized, &layout()).is_err(), + "should bound total JSONL before splitting" + ); +} diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index e207a511b..1a443c679 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -8,17 +8,32 @@ pub(crate) enum ReleaseError { Usage, #[display("Invalid release document")] Document, - #[cfg(test)] + #[display("Release input policy rejects the candidate inputs")] + Policy, + #[display("Inherited release environment is rejected")] + Environment, #[display("Release source is not authenticated")] Source, #[display("Git object verification failed")] GitObject, + #[display("Production release selection is not qualified")] + Unqualified, + #[display("Release inputs are incomplete")] + Incomplete, #[display("Final artifact inspection rejected the executable")] Inspect, #[display("Original release material is not verified")] Material, #[display("Bounded release child process failed")] Process, + #[display("Release network-denial boundary rejected the execution")] + Boundary, + #[display("Release recipe inputs or execution plan are rejected")] + Recipe, + #[display("Final Fastly runtime acceptance rejected the executable")] + Runtime, + #[display("Artifact composition evidence is rejected")] + Composition, } impl core::error::Error for ReleaseError {} diff --git a/crates/trusted-server-release/src/inspect/elf.rs b/crates/trusted-server-release/src/inspect/elf.rs index 13c6cec13..6c5b455a3 100644 --- a/crates/trusted-server-release/src/inspect/elf.rs +++ b/crates/trusted-server-release/src/inspect/elf.rs @@ -47,7 +47,7 @@ const LINUX_X86_64_NEEDED: &[&str] = &[ /// runtime string is not a build path. Complete GNU recipe acceptance also /// requires read-only unbound scratch, IPC without writable shared memory, /// no image-declared volumes, and the bound host/execution-root scan in -/// `crate::recipe::PreparedRecipe`. This list alone cannot establish build +/// [`crate::recipe::PreparedRecipe`]. This list alone cannot establish build /// path provenance. The default macOS list retains `/tmp/` until its own /// filesystem boundary is qualified. const LINUX_X86_64_DENIED_PATH_PREFIXES: &[&str] = &[ diff --git a/crates/trusted-server-release/src/javascript.rs b/crates/trusted-server-release/src/javascript.rs new file mode 100644 index 000000000..5c8b90d82 --- /dev/null +++ b/crates/trusted-server-release/src/javascript.rs @@ -0,0 +1,861 @@ +//! Full observed two-installation JS builds and private retained completion. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::boundary::{Boundary, DenialProof}; +use crate::documents::{RawBytes, Role, parse_json, read_bounded_with_check}; +use crate::error::ReleaseError; +use crate::preparation::{JsAttempt, PreparedJsBuild, preparation_error}; +use crate::process::ProcessOutcome; +use crate::source::join_inventory; + +pub(crate) mod contributions; +pub(crate) use contributions::RetainedContributions; + +/// Exact retained module facts produced by the actual two-side comparison. +struct Module { + filename: String, + size: u64, + sha256: String, +} + +/// Complete module identity exported only from a rehashed owned JS handoff. +#[derive(Clone, Debug, serde::Serialize)] +pub(crate) struct RetainedJsModule { + id: String, + filename: String, + size: u64, + sha256: String, +} + +impl RetainedJsModule { + /// The approved discovered module identity. + pub(crate) fn id(&self) -> &str { + &self.id + } + /// The checked flat bundle filename. + pub(crate) fn filename(&self) -> &str { + &self.filename + } + /// Complete final bundle length. + pub(crate) fn size(&self) -> u64 { + self.size + } + /// SHA-256 of complete final bundle bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } +} + +/// Captured immediately after the collector exits, before later writable stages. +struct CapturedInventory { + raw: RawBytes, + modules: BTreeMap, +} + +fn verify_captured_output( + output: &Path, + captured: &CapturedInventory, + check: &dyn Fn() -> Result<(), Report>, +) -> Result<(), Report> { + check()?; + let current = + read_bounded_with_check(&output.join("tsjs-inventory.json"), Role::Metadata, check)?; + if current.bytes != captured.raw.bytes || current.sha256 != captured.raw.sha256 { + return Err(preparation_error( + "original collector inventory changed after measurement", + )); + } + let bundles = output.join("bundles"); + let expected: BTreeSet<_> = captured + .modules + .values() + .map(|module| module.filename.clone()) + .collect(); + if filenames(&bundles, expected.len())? != expected { + return Err(preparation_error("original collector module set changed")); + } + for module in captured.modules.values() { + let current = + read_bounded_with_check(&bundles.join(&module.filename), Role::Bundle, check)?; + if current.sha256 != module.sha256 || current.bytes.len() as u64 != module.size { + return Err(preparation_error("original collector module bytes changed")); + } + } + check() +} + +fn settings_digest(fingerprints: &[[u8; 32]; 2]) -> String { + let mut digest = Sha256::new(); + digest.update(b"source-owned-js-two-install-v2\nnode24.20.0\nnpm11.19.0\nLinux-owned-PID-cleanup\noffline-include-dev\ninstall-umask022\nLANG=C.UTF-8\nLC_ALL=C.UTF-8\nTZ=UTC\nrelease-tests-1\nfull-registered-typecheck\nfull-final-module-bytes\n"); + for fingerprint in fingerprints { + digest.update(fingerprint); + } + hex::encode(digest.finalize()) +} + +fn finalize_execution( + execution: Result>, + cleanup: Result<(), Report>, +) -> Result> { + match (execution, cleanup) { + (Ok(completed), Ok(())) => { + completed.rehash_for_handoff()?; + Ok(completed) + } + (Ok(_), Err(error)) => Err(error), + (Err(error), Ok(())) => Err(error), + (Err(error), Err(cleanup)) => { + Err(error.attach(format!("private JS cleanup also failed: {cleanup:?}"))) + } + } +} + +/// A completed private execution, unavailable from JSON assertions or paths. +/// +/// Only the actual successful preparation/install/build/test/negative/control/ +/// equality path below constructs this value. Production tool selection remains +/// unavailable until authenticated bootstrap and qualification are complete. +pub(crate) struct JsBuildCompleted { + directory: tempfile::TempDir, + source_commit: String, + tools_sha256: String, + settings_sha256: String, + inventory_sha256: String, + observation_sha256: String, + modules: BTreeMap, + tests: [u64; 2], + attempt: JsAttempt, +} + +impl JsBuildCompleted { + /// Exports conservative rendered origins from the checked original inventory. + /// + /// # Errors + /// + /// Rejects changed handoffs, expired attempts and inconsistent origin joins. + pub(crate) fn retained_contributions( + &self, + ) -> Result> { + contributions::export(self) + } + /// Exports module facts after checking the complete retained handoff. + /// + /// # Errors + /// + /// Rejects changed files, aliases or expired preparation attempts. + pub(crate) fn retained_modules(&self) -> Result, Report> { + self.rehash_for_handoff()?; + Ok(self + .modules + .iter() + .map(|(id, module)| RetainedJsModule { + id: id.clone(), + filename: module.filename.clone(), + size: module.size, + sha256: module.sha256.clone(), + }) + .collect()) + } + + /// Returns the approved source bound to the executed build. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } + /// Returns the exact selected native tool-input digest. + pub(crate) fn tools_sha256(&self) -> &str { + &self.tools_sha256 + } + /// Returns the fixed recipe and both actual execution-boundary settings digest. + pub(crate) fn settings_sha256(&self) -> &str { + &self.settings_sha256 + } + /// Returns the raw original A inventory digest. + pub(crate) fn inventory_sha256(&self) -> &str { + &self.inventory_sha256 + } + /// Returns the successful registered test counts of both independent JS builds. + pub(crate) fn test_counts(&self) -> [u64; 2] { + self.tests + } + + /// Rejoins the retained A inventory and all exact module bytes before handoff. + /// + /// # Errors + /// + /// Returns an error if an owned retained file changed, vanished or appeared. + pub(crate) fn rehash_for_handoff(&self) -> Result<&Path, Report> { + self.attempt.check()?; + let expected = BTreeSet::from([ + "bundles".to_owned(), + "tsjs-inventory.json".to_owned(), + "npm-observation.json".to_owned(), + ]); + if filenames(self.directory.path(), expected.len())? != expected { + return Err(preparation_error( + "retained JS root contains an unrecorded entry", + )); + } + let inventory = read_bounded_with_check( + &self.directory.path().join("tsjs-inventory.json"), + Role::Metadata, + &|| self.attempt.check(), + )?; + if inventory.sha256 != self.inventory_sha256 { + return Err(preparation_error("retained original JS inventory changed")); + } + let observation = read_bounded_with_check( + &self.directory.path().join("npm-observation.json"), + Role::Metadata, + &|| self.attempt.check(), + )?; + if observation.sha256 != self.observation_sha256 { + return Err(preparation_error("retained actual npm observation changed")); + } + let bundles = self.directory.path().join("bundles"); + let expected: BTreeSet<_> = self + .modules + .values() + .map(|module| module.filename.clone()) + .collect(); + let actual = filenames(&bundles, expected.len())?; + if actual != expected { + return Err(preparation_error("retained JS module set changed")); + } + for module in self.modules.values() { + let raw = + read_bounded_with_check(&bundles.join(&module.filename), Role::Bundle, &|| { + self.attempt.check() + })?; + if raw.sha256 != module.sha256 || raw.bytes.len() as u64 != module.size { + return Err(preparation_error("retained JS module changed")); + } + } + Ok(self.directory.path()) + } +} + +#[cfg(test)] +impl JsBuildCompleted { + /// Builds a retained fixture bound to `source_commit` without executing a build. + /// + /// Only tests construct this; production completion stays on the real path. + pub(crate) fn fixture_for_tests(source_commit: &str) -> Self { + let directory = tempfile::tempdir().expect("should create retained fixture"); + fs::create_dir(directory.path().join("bundles")).expect("should create bundle root"); + let inventory = b"fictional original inventory"; + let observation = b"fictional private observation"; + let module = b"fictional module"; + fs::write(directory.path().join("tsjs-inventory.json"), inventory) + .expect("should write exact fixture inventory"); + fs::write(directory.path().join("npm-observation.json"), observation) + .expect("should write exact fixture observation"); + fs::write(directory.path().join("bundles/tsjs-core.js"), module) + .expect("should write fixture module"); + JsBuildCompleted { + directory, + source_commit: source_commit.to_owned(), + tools_sha256: "b".repeat(64), + settings_sha256: "c".repeat(64), + inventory_sha256: hex::encode(Sha256::digest(inventory)), + observation_sha256: hex::encode(Sha256::digest(observation)), + modules: BTreeMap::from([( + "core".to_owned(), + Module { + filename: "tsjs-core.js".to_owned(), + size: module.len() as u64, + sha256: hex::encode(Sha256::digest(module)), + }, + )]), + tests: [1, 1], + // Real-fixture denial cases bind after a full Apple developer-tool + // verify, which exceeded a 30-second lifetime on a hosted + // macos-15 runner (run 39552419463), so allow ten minutes. + attempt: JsAttempt::new( + std::time::Instant::now() + std::time::Duration::from_secs(10 * 60), + crate::process::Cancellation::default(), + ), + } + } +} + +fn filenames(root: &Path, limit: usize) -> Result, Report> { + if !fs::symlink_metadata(root) + .map_err(preparation_error)? + .is_dir() + { + return Err(preparation_error( + "retained JS directory changed type or became an alias", + )); + } + let mut names = BTreeSet::new(); + for entry in fs::read_dir(root).map_err(preparation_error)? { + let name = entry + .map_err(preparation_error)? + .file_name() + .into_string() + .map_err(|_| preparation_error("bundle name is not UTF-8"))?; + if !names.insert(name) || names.len() > limit { + return Err(preparation_error("unexpected retained module entries")); + } + } + Ok(names) +} + +fn inventory_modules( + bytes: &[u8], + expected: &[String], +) -> Result, Report> { + let inventory = parse_json(bytes, Role::Metadata)?; + let array = inventory["modules"] + .as_array() + .ok_or_else(|| preparation_error("JS inventory has no module table"))?; + let mut modules = BTreeMap::new(); + let mut previous = None::; + for entry in array { + let id = entry["id"] + .as_str() + .ok_or_else(|| preparation_error("JS module has no id"))?; + if !expected.iter().any(|expected| expected == id) + || previous.as_deref().is_some_and(|previous| previous >= id) + { + return Err(preparation_error( + "JS module set is untracked, duplicate or unordered", + )); + } + let filename = entry["filename"] + .as_str() + .ok_or_else(|| preparation_error("JS module has no filename"))?; + if filename != format!("tsjs-{id}.js") { + return Err(preparation_error( + "JS module filename differs from tracked identity", + )); + } + let size = entry["size"] + .as_u64() + .filter(|size| *size > 0 && *size <= 32 * 1024 * 1024) + .ok_or_else(|| preparation_error("JS module size is invalid"))?; + let sha256 = entry["sha256"] + .as_str() + .filter(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + .ok_or_else(|| preparation_error("JS module digest is invalid"))?; + modules.insert( + id.to_owned(), + Module { + filename: filename.to_owned(), + size, + sha256: sha256.to_owned(), + }, + ); + previous = Some(id.to_owned()); + } + if modules.keys().ne(expected.iter()) { + return Err(preparation_error( + "JS inventory does not cover every tracked module", + )); + } + Ok(modules) +} + +fn compare_modules( + first: &Path, + second: &Path, + modules: &BTreeMap, + check: &dyn Fn() -> Result<(), Report>, +) -> Result<(), Report> { + let expected: BTreeSet<_> = modules + .values() + .map(|module| module.filename.clone()) + .collect(); + if filenames(first, expected.len())? != expected + || filenames(second, expected.len())? != expected + { + return Err(preparation_error( + "independent JS build bundle sets differ from approved source", + )); + } + for module in modules.values() { + check()?; + let first = read_bounded_with_check(&first.join(&module.filename), Role::Bundle, check)?; + let second = read_bounded_with_check(&second.join(&module.filename), Role::Bundle, check)?; + if first.bytes != second.bytes + || first.sha256 != module.sha256 + || first.bytes.len() as u64 != module.size + { + return Err(preparation_error( + "final JS module bytes differ between the independent builds", + )); + } + } + Ok(()) +} + +fn test_report( + bytes: &[u8], + expected: &BTreeSet, + execution_library: &Path, +) -> Result> { + let report = parse_json(bytes, Role::Companion)?; + let total = report["numTotalTests"] + .as_u64() + .filter(|total| *total > 0) + .ok_or_else(|| preparation_error("registered JS test suite selected zero tests"))?; + if report["success"] != true + || report["numFailedTests"] != 0 + || report["numPassedTests"] != total + || report["numPendingTests"] != 0 + { + return Err(preparation_error( + "full registered JS tests did not all pass", + )); + } + let results = report["testResults"] + .as_array() + .ok_or_else(|| preparation_error("JS tests omitted per-file results"))?; + let mut actual = BTreeSet::new(); + for result in results { + let name = result["name"] + .as_str() + .ok_or_else(|| preparation_error("JS test result lacks its file"))?; + let relative = Path::new(name) + .strip_prefix(execution_library) + .map_err(preparation_error)? + .to_str() + .ok_or_else(|| preparation_error("JS test result is not UTF-8"))?; + if result["status"] != "passed" || !expected.contains(relative) { + return Err(preparation_error( + "JS test file failed or is not registered", + )); + } + actual.insert(relative.to_owned()); + } + if actual != *expected { + return Err(preparation_error( + "JS test/type-check report omits registered files", + )); + } + Ok(total) +} + +// Failure diagnostics deliberately exclude assertions, stacks and child output. +fn vitest_failure_diagnostic( + bytes: &[u8], + expected: &BTreeSet, + execution_library: &Path, +) -> Result> { + let report = parse_json(bytes, Role::Companion)?; + let results = report["testResults"] + .as_array() + .ok_or_else(|| preparation_error("Vitest report lacks suite results"))?; + let sanitize = |name: &str| -> String { + name.chars() + .take(256) + .map(|character| { + if character.is_ascii() && !character.is_ascii_control() { + character + } else { + '?' + } + }) + .collect() + }; + let mut names = Vec::new(); + for result in results { + if names.len() >= 16 { + break; + } + let Some(relative) = result["name"].as_str().and_then(|name| { + Path::new(name) + .strip_prefix(execution_library) + .ok()? + .to_str() + }) else { + continue; + }; + if !expected.contains(relative) { + continue; + } + if result["status"] == "failed" { + names.push(format!("suite failed: {}", sanitize(relative))); + } + if let Some(assertions) = result["assertionResults"].as_array() { + for assertion in assertions { + if names.len() >= 16 { + break; + } + if assertion["status"] == "failed" + && let Some(name) = assertion["fullName"].as_str() + { + names.push(format!("test failed: {}", sanitize(name))); + } + } + } + } + Ok(if names.is_empty() { + "Vitest failed without registered failed names".to_owned() + } else { + format!("Vitest failed names (at most 16): {}", names.join("; ")) + }) +} + +struct MissingFiles { + paths: Vec<(PathBuf, PathBuf)>, + _directory: tempfile::TempDir, +} +impl MissingFiles { + fn remove(paths: &[PathBuf]) -> Result> { + let directory = tempfile::tempdir().map_err(preparation_error)?; + let mut missing = Self { + paths: Vec::new(), + _directory: directory, + }; + for (index, path) in paths.iter().enumerate() { + let destination = missing._directory.path().join(index.to_string()); + fs::rename(path, &destination).map_err(preparation_error)?; + missing.paths.push((path.clone(), destination)); + } + Ok(missing) + } + fn restore(&mut self) -> Result<(), Report> { + while let Some((original, temporary)) = self.paths.last() { + fs::rename(temporary, original).map_err(preparation_error)?; + self.paths.pop(); + } + Ok(()) + } +} +impl Drop for MissingFiles { + fn drop(&mut self) { + let _ = self.restore(); + } +} + +impl PreparedJsBuild<'_> { + fn prohibited_install_download_probe( + &self, + index: usize, + boundary: &Boundary, + proof: &DenialProof, + captured: &CapturedInventory, + ) -> Result<(), Report> { + let side = &self.sides[index]; + let probe = side + .state + .path() + .join("install-download/node_modules/esbuild"); + fs::create_dir_all(probe.join("lib")).map_err(preparation_error)?; + let mut originals = BTreeMap::new(); + for relative in ["install.js", "package.json", "lib/main.js"] { + let raw = read_bounded_with_check( + &side.library().join("node_modules/esbuild").join(relative), + Role::Bundle, + &|| self.attempt.check(), + )?; + fs::write(probe.join(relative), &raw.bytes).map_err(preparation_error)?; + originals.insert(relative, raw.sha256); + } + // This separate tree contains neither a native package nor an optimized + // binary. Omitting npm from PATH exercises esbuild's actual direct + // download fallback without waiting through npm's network retries. + let command = self + .command_with_npm_path(index, boundary, false)? + .arguments([boundary + .execution_path(&probe.join("install.js"))? + .into_os_string()]); + let result = boundary.run_confined(&command, proof)?; + verify_captured_output(&side.output(), captured, &|| self.attempt.check())?; + let stderr = std::str::from_utf8(result.stderr()).map_err(preparation_error)?; + if result.outcome() != (ProcessOutcome::Exited { code: 1 }) + || !stderr.contains("[esbuild] Trying to download ") + || !stderr.contains("Failed to install package") + { + return Err(preparation_error( + "actual missing-native install download did not fail under denial", + )); + } + let mut pending = vec![probe.clone()]; + let mut remaining = originals; + while let Some(directory) = pending.pop() { + self.attempt.check()?; + for entry in fs::read_dir(directory).map_err(preparation_error)? { + let entry = entry.map_err(preparation_error)?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(preparation_error)?; + if metadata.is_dir() { + pending.push(path); + } else { + let relative = path + .strip_prefix(&probe) + .map_err(preparation_error)? + .to_str() + .ok_or_else(|| preparation_error("probe member is not UTF-8"))?; + let expected = remaining.remove(relative).ok_or_else(|| { + preparation_error("denied install produced a replacement native tool") + })?; + if read_bounded_with_check(&path, Role::Bundle, &|| self.attempt.check())? + .sha256 + != expected + { + return Err(preparation_error("denied install changed probe inputs")); + } + } + } + } + if !remaining.is_empty() { + return Err(preparation_error("denied install removed probe inputs")); + } + side.verify_source()?; + self.check() + } + + fn missing_tool_probes( + &self, + index: usize, + boundary: &Boundary, + proof: &DenialProof, + captured: &CapturedInventory, + ) -> Result<(), Report> { + let side = &self.sides[index]; + let architecture = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + _ => { + return Err(preparation_error( + "unsupported native JS probe architecture", + )); + } + }; + let cases = [ + (vec![side.library().join(format!("node_modules/@esbuild/linux-{architecture}/bin/esbuild")), side.library().join("node_modules/esbuild/bin/esbuild"), side.library().join("node_modules/.bin/esbuild")], "const e=require('esbuild'); e.transformSync('const x=1',{minify:true})"), + (vec![side.library().join(format!("node_modules/@rollup/rollup-linux-{architecture}-gnu/rollup.linux-{architecture}-gnu.node"))], "require('rollup')"), + ]; + for (paths, operation) in cases { + let mut missing = MissingFiles::remove(&paths)?; + let script = format!( + "try{{{operation};process.exitCode=0}}catch{{process.stdout.write('TS_RELEASE_MISSING_TOOL_DENIED\\n');process.exitCode=73}}" + ); + let command = self + .command(index, boundary)? + .arguments([OsString::from("--eval"), OsString::from(script)]); + let result = boundary.run_confined(&command, proof); + missing.restore()?; + let result = result?; + verify_captured_output(&side.output(), captured, &|| self.attempt.check())?; + if result.outcome() != (ProcessOutcome::Exited { code: 73 }) + || result.stdout() != b"TS_RELEASE_MISSING_TOOL_DENIED\n" + { + return Err(preparation_error( + "missing native tool fetched a substitute or lacked an actual denied failure", + )); + } + side.verify_source()?; + self.check()?; + } + Ok(()) + } + + /// Executes both independent full builds under their exact proven boundaries. + /// + /// # Errors + /// + /// Returns an error for any input mutation, installation/hook/control failure, + /// failed or incomplete test/type-check suite, or any final module difference. + pub(crate) fn run( + self, + boundaries: [&Boundary; 2], + proofs: [&DenialProof; 2], + ) -> Result> { + let execution = self.execute(boundaries, proofs); + let cleanup = self.cleanup(); + finalize_execution(execution, cleanup) + } + + fn execute( + &self, + boundaries: [&Boundary; 2], + proofs: [&DenialProof; 2], + ) -> Result> { + for (index, boundary) in boundaries.iter().enumerate() { + if !boundary.matches_roots(&self.boundary_roots(index)?) + || !boundary.has_owned_descendant_cleanup() + { + return Err(preparation_error( + "JS execution requires independent exact Linux boundary roots", + )); + } + } + let mut counts = [0_u64; 2]; + let mut observations = Vec::new(); + let mut captured_inventories = Vec::new(); + for index in 0..2 { + observations.push(self.install(index, boundaries[index], proofs[index])?); + self.run_command( + index, + boundaries[index], + proofs[index], + self.collector_arguments(index, boundaries[index])?, + )?; + let side = &self.sides[index]; + let raw = read_bounded_with_check( + &side.output().join("tsjs-inventory.json"), + Role::Metadata, + &|| self.attempt.check(), + )?; + join_inventory(self.source, &raw.bytes)?; + let modules = inventory_modules(&raw.bytes, &self.module_ids)?; + let captured = CapturedInventory { raw, modules }; + verify_captured_output(&side.output(), &captured, &|| self.attempt.check())?; + self.missing_tool_probes(index, boundaries[index], proofs[index], &captured)?; + self.prohibited_install_download_probe( + index, + boundaries[index], + proofs[index], + &captured, + )?; + let arguments = vec![ + boundaries[index] + .execution_path(&side.library().join("node_modules/vitest/vitest.mjs"))? + .into_os_string(), + OsString::from("run"), + OsString::from("--reporter=json"), + OsString::from("--outputFile"), + boundaries[index] + .execution_path(&side.output().join("vitest.json"))? + .into_os_string(), + ]; + if let Err(mut error) = + self.run_command(index, boundaries[index], proofs[index], arguments) + { + let diagnostic = read_bounded_with_check( + &side.output().join("vitest.json"), + Role::Companion, + &|| self.attempt.check(), + ) + .and_then(|report| { + vitest_failure_diagnostic( + &report.bytes, + &side.expected_tests, + &boundaries[index].execution_path(&side.library())?, + ) + }); + error = error.attach( + diagnostic + .unwrap_or_else(|_| "Bounded Vitest failure names unavailable".to_owned()), + ); + if let Err(changed) = + verify_captured_output(&side.output(), &captured, &|| self.attempt.check()) + { + error = error.attach(format!( + "post-failure collector recheck failed: {changed:?}" + )); + } + if let Err(changed) = side.verify_source() { + error = + error.attach(format!("post-failure source recheck failed: {changed:?}")); + } + return Err(error); + } + verify_captured_output(&side.output(), &captured, &|| self.attempt.check())?; + let report = read_bounded_with_check( + &side.output().join("vitest.json"), + Role::Companion, + &|| self.attempt.check(), + )?; + counts[index] = test_report( + &report.bytes, + &side.expected_tests, + &boundaries[index].execution_path(&side.library())?, + )?; + side.verify_source()?; + captured_inventories.push(captured); + } + self.check()?; + for (index, captured) in captured_inventories.iter().enumerate() { + verify_captured_output(&self.sides[index].output(), captured, &|| { + self.attempt.check() + })?; + } + let [first, second]: [CapturedInventory; 2] = captured_inventories + .try_into() + .map_err(|_| preparation_error("both original collector inventories are required"))?; + let modules = first.modules; + for (id, first) in &modules { + let last = second + .modules + .get(id) + .ok_or_else(|| preparation_error("B omitted a tracked module"))?; + if first.sha256 != last.sha256 || first.size != last.size { + return Err(preparation_error( + "independent JS build inventory module measurements differ", + )); + } + } + compare_modules( + &self.sides[0].output().join("bundles"), + &self.sides[1].output().join("bundles"), + &modules, + &|| self.attempt.check(), + )?; + let directory = tempfile::tempdir().map_err(preparation_error)?; + fs::create_dir(directory.path().join("bundles")).map_err(preparation_error)?; + fs::write( + directory.path().join("tsjs-inventory.json"), + &first.raw.bytes, + ) + .map_err(preparation_error)?; + let observation = &observations + .first() + .ok_or_else(|| preparation_error("actual A installation observation is absent"))? + .raw; + fs::write(directory.path().join("npm-observation.json"), observation) + .map_err(preparation_error)?; + for module in modules.values() { + let raw = read_bounded_with_check( + &self.sides[0] + .output() + .join("bundles") + .join(&module.filename), + Role::Bundle, + &|| self.attempt.check(), + )?; + if raw.sha256 != module.sha256 || raw.bytes.len() as u64 != module.size { + return Err(preparation_error("A output changed before retention")); + } + fs::write( + directory.path().join("bundles").join(&module.filename), + raw.bytes, + ) + .map_err(preparation_error)?; + } + let completed = JsBuildCompleted { + directory, + source_commit: self.source.commit().as_str().to_owned(), + tools_sha256: self.tools_digest.clone(), + settings_sha256: settings_digest(&[ + boundaries[0].execution_fingerprint(), + boundaries[1].execution_fingerprint(), + ]), + inventory_sha256: first.raw.sha256, + observation_sha256: hex::encode(Sha256::digest(observation)), + modules, + tests: counts, + attempt: self.attempt.clone(), + }; + completed.rehash_for_handoff()?; + Ok(completed) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/javascript/contributions.rs b/crates/trusted-server-release/src/javascript/contributions.rs new file mode 100644 index 000000000..25fb2f957 --- /dev/null +++ b/crates/trusted-server-release/src/javascript/contributions.rs @@ -0,0 +1,770 @@ +//! Checked informational exports; rendered membership is conservative, not final-function attribution. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use serde_json::Value; +#[cfg(test)] +use sha2::Digest as _; + +use super::{JsBuildCompleted, inventory_modules}; +use crate::documents::{Role, parse_json, read_bounded_with_check}; +use crate::error::ReleaseError; +use crate::preparation::preparation_error; + +type Result = core::result::Result>; + +/// Original collector facts exported only after the owning handoff is rehashed. +/// +/// These observations grant no execution, qualification or complete-coverage authority. +#[derive(Debug, serde::Serialize)] +pub(crate) struct RetainedContributions { + #[serde(skip)] + raw_inventory: Vec, + inventory_sha256: String, + inventory: Inventory, +} + +#[derive(Debug, serde::Serialize)] +struct Inventory { + schema_version: u64, + source_commit: String, + modules: Vec, + materials: Vec, + tools: Vec, +} + +/// Conservative post-Rollup origins for a complete final bundle. +#[derive(Debug, serde::Serialize)] +pub(crate) struct ModuleContributions { + id: String, + filename: String, + size: u64, + sha256: String, + contributions: Vec, +} + +/// A positively rendered origin; later minification can remove its code. +#[derive(Debug, serde::Serialize)] +pub(crate) struct Contribution { + #[serde(flatten)] + origin: Origin, + stage: String, + rendered_length: u64, +} + +/// Checked original-source identity or a reviewed generated producer observation. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub(crate) enum Origin { + Source { + path: String, + transform: String, + sha256: String, + }, + Npm { + lock_path: String, + package_name: String, + package_version: String, + integrity: String, + path: String, + transform: String, + sha256: String, + }, + Generated { + id: String, + plugin: String, + owner_lock_path: String, + generated_sha256: String, + #[serde(skip_serializing_if = "Option::is_none")] + input: Option>, + }, +} + +/// Retained material observations, with structured trees distinct from raw files. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub(crate) enum Material { + Source { + id: String, + path: String, + sha256: String, + }, + NpmPackage { + id: String, + lock_path: String, + name: String, + version: String, + integrity: String, + installed_tree_sha256: String, + }, + ToolDistribution { + id: String, + name: String, + version: String, + sha256: String, + }, + LifecycleEvent { + id: String, + package_material_id: String, + event: String, + script_sha256: String, + }, + LifecycleOutput { + id: String, + package_material_id: String, + event: String, + script_sha256: String, + path: String, + size: u64, + sha256: String, + }, +} + +/// A collector tool role joined to its measured distribution material. +#[derive(Debug, serde::Serialize)] +pub(crate) struct Tool { + name: String, + version: String, + role: String, + material_id: String, +} + +impl RetainedContributions { + /// Returns the exact original collector inventory bytes. + pub(crate) fn raw_inventory(&self) -> &[u8] { + &self.raw_inventory + } + /// Returns the approved collector source identity. + pub(crate) fn source_commit(&self) -> &str { + &self.inventory.source_commit + } + /// Returns the raw original inventory digest, not a reserialization digest. + pub(crate) fn inventory_sha256(&self) -> &str { + &self.inventory_sha256 + } + /// Returns the checked conservative bundle observations. + pub(crate) fn modules(&self) -> &[ModuleContributions] { + &self.inventory.modules + } + /// Returns observed materials without implying they contribute runtime bytes. + pub(crate) fn materials(&self) -> &[Material] { + &self.inventory.materials + } + /// Returns checked tool-to-distribution joins from the original collector. + pub(crate) fn tools(&self) -> &[Tool] { + &self.inventory.tools + } +} + +impl ModuleContributions { + /// Returns the checked complete bundle filename. + pub(crate) fn filename(&self) -> &str { + &self.filename + } + /// Returns complete final bundle length. + pub(crate) fn size(&self) -> u64 { + self.size + } + /// Returns the discovered module identity. + pub(crate) fn id(&self) -> &str { + &self.id + } + /// Returns the complete final bundle digest. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + /// Returns conservative rendered origins, before final minification. + pub(crate) fn contributions(&self) -> &[Contribution] { + &self.contributions + } +} + +impl Contribution { + /// Returns the observed contribution stage before later minification. + pub(crate) fn stage(&self) -> &str { + &self.stage + } + /// Returns checked ownership without implying final-function reachability. + pub(crate) fn origin(&self) -> &Origin { + &self.origin + } + /// Returns positive length at the recorded Rollup observation stage. + pub(crate) fn rendered_length(&self) -> u64 { + self.rendered_length + } +} + +impl Tool { + /// Returns the observed tool identity. + pub(crate) fn name(&self) -> &str { + &self.name + } + /// Returns the observed tool version. + pub(crate) fn version(&self) -> &str { + &self.version + } + /// Returns its recorded build role. + pub(crate) fn role(&self) -> &str { + &self.role + } + /// Returns its checked distribution material identity. + pub(crate) fn material_id(&self) -> &str { + &self.material_id + } +} + +pub(super) fn export(completed: &JsBuildCompleted) -> Result { + let root = completed.rehash_for_handoff()?; + let raw = read_bounded_with_check(&root.join("tsjs-inventory.json"), Role::Metadata, &|| { + completed.attempt.check() + })?; + if raw.sha256 != completed.inventory_sha256 { + return Err(preparation_error( + "original JS inventory changed during export", + )); + } + let value = parse_json(&raw.bytes, Role::Metadata)?; + fields( + &value, + &[ + "schema_version", + "source_commit", + "modules", + "materials", + "tools", + ], + )?; + if value["schema_version"] != 1 || value["source_commit"] != completed.source_commit { + return Err(preparation_error( + "JS contribution source or schema differs", + )); + } + let expected: Vec<_> = completed.modules.keys().cloned().collect(); + let modules = inventory_modules(&raw.bytes, &expected)?; + for (id, parsed) in &modules { + let owned = &completed.modules[id]; + if parsed.filename != owned.filename + || parsed.size != owned.size + || parsed.sha256 != owned.sha256 + { + return Err(preparation_error( + "contribution module differs from owned module", + )); + } + } + let material_values = array(&value["materials"])?; + let mut material_ids = BTreeMap::new(); + let mut materials = Vec::new(); + for entry in material_values { + let id = text(entry, "id")?; + if material_ids.insert(id.clone(), entry).is_some() { + return Err(preparation_error("duplicate JS material identity")); + } + materials.push(material(entry)?); + } + for material in &materials { + validate_material_join(material, &material_ids)?; + } + let mut exported_modules = Vec::new(); + let mut original_files = BTreeMap::new(); + for entry in array(&value["modules"])? { + fields( + entry, + &["id", "filename", "size", "sha256", "contributions"], + )?; + let mut contributions = Vec::new(); + let mut origins = BTreeSet::new(); + for observed in array(&entry["contributions"])? { + if observed["stage"] != "rollup-rendered" { + return Err(preparation_error("unknown JS contribution stage")); + } + let rendered_length = observed["rendered_length"] + .as_u64() + .filter(|n| *n > 0 && *n <= 32 * 1024 * 1024) + .ok_or_else(|| preparation_error("invalid rendered contribution length"))?; + let origin = origin(observed, &material_ids, true)?; + consistent_originals(&origin, &mut original_files)?; + let identity = origin_key(&origin); + if !origins.insert(identity) { + return Err(preparation_error("duplicate JS rendered origin")); + } + contributions.push(Contribution { + origin, + stage: "rollup-rendered".to_owned(), + rendered_length, + }); + } + let module = &modules[&text(entry, "id")?]; + exported_modules.push(ModuleContributions { + id: text(entry, "id")?, + filename: module.filename.clone(), + size: module.size, + sha256: module.sha256.clone(), + contributions, + }); + } + let mut names = BTreeSet::new(); + let mut tools = Vec::new(); + for entry in array(&value["tools"])? { + fields(entry, &["name", "version", "role", "material_id"])?; + let tool = Tool { + name: text(entry, "name")?, + version: text(entry, "version")?, + role: text(entry, "role")?, + material_id: text(entry, "material_id")?, + }; + let material = material_ids + .get(&tool.material_id) + .ok_or_else(|| preparation_error("missing JS tool material"))?; + if material["kind"] != "tool-distribution" + || material["name"] != tool.name + || material["version"] != tool.version + || ![ + "runtime", + "package-manager", + "bundler", + "minifier", + "test-runner", + ] + .contains(&tool.role.as_str()) + || !names.insert(tool.name.clone()) + { + return Err(preparation_error("invalid JS tool ownership")); + } + tools.push(tool); + } + completed.rehash_for_handoff()?; + Ok(RetainedContributions { + raw_inventory: raw.bytes, + inventory_sha256: raw.sha256, + inventory: Inventory { + schema_version: 1, + source_commit: completed.source_commit.clone(), + modules: exported_modules, + materials, + tools, + }, + }) +} + +fn origin(value: &Value, materials: &BTreeMap, rendered: bool) -> Result { + let mut keys = match value["kind"].as_str() { + Some("source") => vec!["kind", "path", "transform", "sha256"], + Some("npm") => vec![ + "kind", + "lock_path", + "package_name", + "package_version", + "integrity", + "path", + "transform", + "sha256", + ], + Some("generated") if rendered => { + let mut keys = vec![ + "kind", + "id", + "plugin", + "owner_lock_path", + "generated_sha256", + ]; + if value.get("input").is_some() { + keys.push("input"); + } + keys + } + _ => return Err(preparation_error("unknown JS origin kind")), + }; + if rendered { + keys.extend(["stage", "rendered_length"]); + } + fields(value, &keys)?; + if value["kind"] == "generated" { + let id = value["id"] + .as_str() + .filter(|v| v.len() <= 4096) + .ok_or_else(|| preparation_error("invalid virtual origin identity"))? + .to_owned(); + let plugin = text(value, "plugin")?; + let owner_lock_path = text(value, "owner_lock_path")?; + let owner = materials + .get(&format!("npm:{owner_lock_path}")) + .ok_or_else(|| preparation_error("missing generated producer material"))?; + if owner_lock_path != "node_modules/vite" + || owner["kind"] != "npm-package" + || owner["name"] != "vite" + { + return Err(preparation_error("unreviewed generated owner")); + } + let input = value + .get("input") + .map(|input| origin(input, materials, false).map(Box::new)) + .transpose()?; + let valid = match (&*plugin, input.as_deref()) { + ("commonjs", None) => id == "\0commonjsHelpers.js", + ("vite:build-import-analysis", None) => id == "\0vite/preload-helper.js", + ("commonjs", Some(Origin::Source { path, .. })) => { + id == format!("\0{path}?commonjs-es-import") + } + ( + "commonjs", + Some(Origin::Npm { + lock_path, path, .. + }), + ) => id == format!("\0{lock_path}/{path}?commonjs-es-import"), + _ => false, + }; + if !valid { + return Err(preparation_error( + "unknown or inconsistent generated producer", + )); + } + return Ok(Origin::Generated { + id, + plugin, + owner_lock_path, + generated_sha256: digest(value, "generated_sha256")?, + input, + }); + } + let path = checked_path(value, "path")?; + let transform = text(value, "transform")?; + if !["identity", "raw", "inline"].contains(&transform.as_str()) { + return Err(preparation_error("unknown JS source transformation")); + } + let sha256 = digest(value, "sha256")?; + if value["kind"] == "source" { + if path.split('/').any(|part| part == "node_modules") { + return Err(preparation_error("unmapped npm source ownership")); + } + return Ok(Origin::Source { + path, + transform, + sha256, + }); + } + let lock_path = checked_path(value, "lock_path")?; + let package_name = text(value, "package_name")?; + let package_version = text(value, "package_version")?; + let integrity = integrity(value)?; + let owner = materials + .get(&format!("npm:{lock_path}")) + .ok_or_else(|| preparation_error("missing npm contribution material"))?; + if owner["kind"] != "npm-package" + || owner["lock_path"] != lock_path + || owner["name"] != package_name + || owner["version"] != package_version + || owner["integrity"] != integrity + { + return Err(preparation_error( + "npm contribution differs from installed ownership", + )); + } + if path.split('/').any(|part| part == "node_modules") { + return Err(preparation_error("nested npm input needs its own owner")); + } + Ok(Origin::Npm { + lock_path, + package_name, + package_version, + integrity, + path, + transform, + sha256, + }) +} + +fn origin_key(origin: &Origin) -> String { + match origin { + Origin::Source { + path, transform, .. + } => format!("source:{path}:{transform}"), + Origin::Npm { + lock_path, + path, + transform, + .. + } => format!("npm:{lock_path}:{path}:{transform}"), + Origin::Generated { id, .. } => format!("generated:{id}"), + } +} + +fn consistent_originals(origin: &Origin, originals: &mut BTreeMap) -> Result<()> { + let (key, sha256) = match origin { + Origin::Source { path, sha256, .. } => (format!("source:{path}"), sha256), + Origin::Npm { + lock_path, + path, + sha256, + .. + } => (format!("npm:{lock_path}:{path}"), sha256), + Origin::Generated { + id, + generated_sha256, + input, + .. + } => { + if let Some(input) = input { + consistent_originals(input, originals)?; + } + (format!("generated:{id}"), generated_sha256) + } + }; + if originals + .insert(key, sha256.clone()) + .is_some_and(|previous| previous != *sha256) + { + return Err(preparation_error( + "conflicting original JS contribution bytes", + )); + } + Ok(()) +} + +fn material(value: &Value) -> Result { + let id = text(value, "id")?; + Ok(match value["kind"].as_str() { + Some("source") => { + fields(value, &["kind", "id", "path", "sha256"])?; + let path = checked_path(value, "path")?; + if id != format!("source:{path}") { + return Err(preparation_error("invalid source material identity")); + } + Material::Source { + id, + path, + sha256: digest(value, "sha256")?, + } + } + Some("npm-package") => { + fields( + value, + &[ + "kind", + "id", + "lock_path", + "name", + "version", + "integrity", + "installed_tree_sha256", + ], + )?; + let lock_path = checked_path(value, "lock_path")?; + let name = text(value, "name")?; + if id != format!("npm:{lock_path}") + || lock_path.rsplit("node_modules/").next() != Some(name.as_str()) + || !lock_path.starts_with("node_modules/") + { + return Err(preparation_error("invalid npm material identity")); + } + Material::NpmPackage { + id, + lock_path, + name, + version: text(value, "version")?, + integrity: integrity(value)?, + installed_tree_sha256: digest(value, "installed_tree_sha256")?, + } + } + Some("tool-distribution") => { + fields(value, &["kind", "id", "name", "version", "sha256"])?; + Material::ToolDistribution { + id, + name: text(value, "name")?, + version: text(value, "version")?, + sha256: digest(value, "sha256")?, + } + } + Some("lifecycle-event") => { + fields( + value, + &[ + "kind", + "id", + "package_material_id", + "event", + "script_sha256", + ], + )?; + Material::LifecycleEvent { + id, + package_material_id: text(value, "package_material_id")?, + event: event(value)?, + script_sha256: digest(value, "script_sha256")?, + } + } + Some("lifecycle-output") => { + fields( + value, + &[ + "kind", + "id", + "package_material_id", + "event", + "script_sha256", + "path", + "size", + "sha256", + ], + )?; + Material::LifecycleOutput { + id, + package_material_id: text(value, "package_material_id")?, + event: event(value)?, + script_sha256: digest(value, "script_sha256")?, + path: checked_path(value, "path")?, + size: value["size"] + .as_u64() + .filter(|n| *n <= 32 * 1024 * 1024) + .ok_or_else(|| preparation_error("invalid lifecycle output size"))?, + sha256: digest(value, "sha256")?, + } + } + _ => return Err(preparation_error("unknown JS material kind")), + }) +} + +fn validate_material_join(material: &Material, materials: &BTreeMap) -> Result<()> { + match material { + Material::LifecycleEvent { + package_material_id, + .. + } + | Material::LifecycleOutput { + package_material_id, + .. + } => { + let owner = materials + .get(package_material_id) + .ok_or_else(|| preparation_error("missing lifecycle package owner"))?; + if owner["kind"] != "npm-package" { + return Err(preparation_error("invalid lifecycle package owner")); + } + if let Material::LifecycleOutput { + event, + script_sha256, + path, + .. + } = material + { + let lock = owner["lock_path"] + .as_str() + .ok_or_else(|| preparation_error("missing lifecycle lock path"))?; + if !path.starts_with(&format!("{lock}/")) { + return Err(preparation_error("lifecycle output escapes owner")); + } + let joined = materials.values().any(|entry| { + entry["kind"] == "lifecycle-event" + && entry["package_material_id"] == *package_material_id + && entry["event"] == *event + && entry["script_sha256"] == *script_sha256 + }); + if !joined { + return Err(preparation_error("lifecycle output lacks observed event")); + } + if materials.values().any(|entry| { + entry["kind"] == "npm-package" + && entry["id"] != *package_material_id + && entry["lock_path"].as_str().is_some_and(|nested| { + nested.starts_with(&format!("{lock}/")) + && path.starts_with(&format!("{nested}/")) + }) + }) { + return Err(preparation_error( + "lifecycle output belongs to nested package", + )); + } + } + } + _ => {} + } + Ok(()) +} + +fn fields(value: &Value, expected: &[&str]) -> Result<()> { + let object = value + .as_object() + .ok_or_else(|| preparation_error("JS record is not an object"))?; + if object.len() != expected.len() || expected.iter().any(|key| !object.contains_key(*key)) { + return Err(preparation_error("unknown or missing JS record fields")); + } + Ok(()) +} +fn text(value: &Value, key: &str) -> Result { + value[key] + .as_str() + .filter(|v| !v.is_empty() && v.len() <= 4096 && !v.chars().any(char::is_control)) + .map(str::to_owned) + .ok_or_else(|| preparation_error("invalid JS identity text")) +} +fn checked_path(value: &Value, key: &str) -> Result { + let path = text(value, key)?; + if path.starts_with('/') + || path.contains('\\') + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(preparation_error("unsafe JS origin path")); + } + Ok(path) +} +fn digest(value: &Value, key: &str) -> Result { + let digest = text(value, key)?; + if digest.len() != 64 + || !digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(preparation_error("invalid JS SHA-256")); + } + Ok(digest) +} +fn integrity(value: &Value) -> Result { + let integrity = text(value, "integrity")?; + if integrity.len() != 95 + || !integrity.starts_with("sha512-") + || !integrity.ends_with("==") + || !integrity.as_bytes()[7..93] + .iter() + .all(|b| b.is_ascii_alphanumeric() || *b == b'+' || *b == b'/') + { + return Err(preparation_error("invalid npm archive integrity")); + } + Ok(integrity) +} +fn event(value: &Value) -> Result { + let event = text(value, "event")?; + if !["preinstall", "install", "postinstall", "prepare"].contains(&event.as_str()) { + return Err(preparation_error("unknown lifecycle event")); + } + Ok(event) +} +fn array(value: &Value) -> Result<&Vec> { + value + .as_array() + .ok_or_else(|| preparation_error("missing JS observation array")) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +impl JsBuildCompleted { + /// Creates a rehashed fictional owner with checked collector variants. + pub(crate) fn fixture_for_projection(commit: &str) -> Self { + let mut completed = tests::fixture(); + completed.source_commit = commit.to_owned(); + let mut inventory = tests::rich_inventory(&completed); + inventory["source_commit"] = serde_json::json!(commit); + let bytes = serde_json::to_vec(&inventory).expect("should encode fictional inventory"); + std::fs::write( + completed.directory.path().join("tsjs-inventory.json"), + &bytes, + ) + .expect("should write fictional original inventory"); + completed.inventory_sha256 = hex::encode(sha2::Sha256::digest(&bytes)); + completed + } +} diff --git a/crates/trusted-server-release/src/javascript/contributions/tests.rs b/crates/trusted-server-release/src/javascript/contributions/tests.rs new file mode 100644 index 000000000..afb55e784 --- /dev/null +++ b/crates/trusted-server-release/src/javascript/contributions/tests.rs @@ -0,0 +1,201 @@ +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::super::JsBuildCompleted; + +pub(super) fn fixture() -> JsBuildCompleted { + let mut completed = JsBuildCompleted::fixture_for_tests(&"a".repeat(40)); + let module = &completed.modules["core"]; + let inventory = json!({ + "schema_version":1,"source_commit":completed.source_commit, + "modules":[{"id":"core","filename":module.filename,"size":module.size,"sha256":module.sha256, + "contributions":[{"kind":"source","path":"src/example.ts","transform":"identity","sha256":"c".repeat(64),"stage":"rollup-rendered","rendered_length":12}]}], + "tools":[],"materials":[] + }); + replace(&mut completed, &inventory); + completed +} + +fn replace(completed: &mut JsBuildCompleted, inventory: &Value) { + let bytes = serde_json::to_vec(inventory).expect("should encode inventory"); + std::fs::write( + completed.directory.path().join("tsjs-inventory.json"), + &bytes, + ) + .expect("should write retained inventory"); + completed.inventory_sha256 = hex::encode(Sha256::digest(&bytes)); +} + +#[test] +fn checked_export_preserves_original_digest_and_conservative_source_origin() { + let completed = fixture(); + let exported = completed + .retained_contributions() + .expect("should export checked origins"); + let value = serde_json::to_value(exported).expect("should serialize informational export"); + assert_eq!( + value["inventory_sha256"], completed.inventory_sha256, + "should bind raw original inventory" + ); + assert_eq!( + value["inventory"]["modules"][0]["contributions"][0]["path"], "src/example.ts", + "should preserve rendered source origin" + ); +} + +#[test] +fn checked_export_rejects_changed_bundle_before_reading_origins() { + let completed = fixture(); + std::fs::write( + completed.directory.path().join("bundles/tsjs-core.js"), + b"tampered", + ) + .expect("should mutate fixture bundle"); + assert!( + completed.retained_contributions().is_err(), + "should reject changed final module" + ); +} + +#[test] +fn checked_export_rejects_unknown_origin_source_mismatch_and_path_aliases() { + for (pointer, replacement) in [ + ("/source_commit", json!("b".repeat(40))), + ("/modules/0/contributions/0/kind", json!("unknown")), + ("/modules/0/contributions/0/path", json!("src//example.ts")), + ("/modules/0/contributions/0/stage", json!("final-function")), + ("/modules/0/contributions/0/rendered_length", json!(0)), + ] { + let mut completed = fixture(); + let bytes = std::fs::read(completed.directory.path().join("tsjs-inventory.json")) + .expect("should read fixture"); + let mut inventory: Value = serde_json::from_slice(&bytes).expect("should parse fixture"); + *inventory + .pointer_mut(pointer) + .expect("should select fixture field") = replacement; + replace(&mut completed, &inventory); + assert!( + completed.retained_contributions().is_err(), + "should reject invalid origin at {pointer}" + ); + } +} + +pub(super) fn rich_inventory(completed: &JsBuildCompleted) -> Value { + let bytes = std::fs::read(completed.directory.path().join("tsjs-inventory.json")) + .expect("should read fixture"); + let mut inventory: Value = serde_json::from_slice(&bytes).expect("should parse fixture"); + let integrity = format!("sha512-{}==", "A".repeat(86)); + inventory["materials"] = json!([ + {"kind":"npm-package","id":"npm:node_modules/vite","lock_path":"node_modules/vite","name":"vite","version":"1.0.0","integrity":integrity,"installed_tree_sha256":"d".repeat(64)}, + {"kind":"tool-distribution","id":"tool:vite","name":"vite","version":"1.0.0","sha256":"e".repeat(64)}, + {"kind":"source","id":"source:scripts/example.mjs","path":"scripts/example.mjs","sha256":"f".repeat(64)}, + {"kind":"lifecycle-event","id":"lifecycle:node_modules/vite:postinstall","package_material_id":"npm:node_modules/vite","event":"postinstall","script_sha256":"1".repeat(64)}, + {"kind":"lifecycle-output","id":"output:node_modules/vite/generated.js","package_material_id":"npm:node_modules/vite","event":"postinstall","script_sha256":"1".repeat(64),"path":"node_modules/vite/generated.js","size":10,"sha256":"2".repeat(64)} + ]); + inventory["tools"] = + json!([{"name":"vite","version":"1.0.0","role":"bundler","material_id":"tool:vite"}]); + inventory["modules"][0]["contributions"] = json!([ + {"kind":"source","path":"src/example.ts","transform":"raw","sha256":"c".repeat(64),"stage":"rollup-rendered","rendered_length":12}, + {"kind":"npm","lock_path":"node_modules/vite","package_name":"vite","package_version":"1.0.0","integrity":integrity,"path":"dist/example.js","transform":"identity","sha256":"3".repeat(64),"stage":"rollup-rendered","rendered_length":15}, + {"kind":"generated","id":"\u{0}commonjsHelpers.js","plugin":"commonjs","owner_lock_path":"node_modules/vite","generated_sha256":"4".repeat(64),"stage":"rollup-rendered","rendered_length":20}, + {"kind":"generated","id":"\u{0}node_modules/vite/dist/example.js?commonjs-es-import","plugin":"commonjs","owner_lock_path":"node_modules/vite","generated_sha256":"5".repeat(64),"input":{"kind":"npm","lock_path":"node_modules/vite","package_name":"vite","package_version":"1.0.0","integrity":integrity,"path":"dist/example.js","transform":"identity","sha256":"3".repeat(64)},"stage":"rollup-rendered","rendered_length":20} + ]); + inventory +} + +#[test] +fn original_collector_variants_preserve_generated_nul_ids_and_distinct_tree_evidence() { + let mut completed = fixture(); + let inventory = rich_inventory(&completed); + replace(&mut completed, &inventory); + let exported = completed + .retained_contributions() + .expect("should accept actual collector variants"); + assert_eq!( + exported.modules().len(), + 1, + "should export typed bundle observations" + ); + assert_eq!( + exported.materials().len(), + 5, + "should retain every distinct material role" + ); + assert_eq!( + exported.inventory_sha256(), + completed.inventory_sha256, + "should preserve raw digest" + ); + let value = serde_json::to_value(exported).expect("should encode checked origins"); + assert_eq!( + value["inventory"], inventory, + "should preserve all original typed facts" + ); +} + +#[test] +fn mismatched_npm_generated_tool_and_lifecycle_ownership_fail_closed() { + for (pointer, replacement) in [ + ("/modules/0/contributions/1/package_version", json!("2.0.0")), + ( + "/modules/0/contributions/1/path", + json!("node_modules/other/example.js"), + ), + ("/modules/0/contributions/2/id", json!("\u{0}unknown.js")), + ("/modules/0/contributions/2/plugin", json!("unreviewed")), + ( + "/modules/0/contributions/3/input/sha256", + json!("6".repeat(64)), + ), + ("/tools/0/material_id", json!("npm:node_modules/vite")), + ("/materials/4/script_sha256", json!("6".repeat(64))), + ("/materials/4/path", json!("node_modules/other/output.js")), + ] { + let mut completed = fixture(); + let mut inventory = rich_inventory(&completed); + *inventory + .pointer_mut(pointer) + .expect("should select ownership field") = replacement; + replace(&mut completed, &inventory); + assert!( + completed.retained_contributions().is_err(), + "should reject inconsistent ownership at {pointer}" + ); + } +} + +#[test] +fn duplicate_contribution_and_material_ownership_fail_closed() { + for duplicate_material in [false, true] { + let mut completed = fixture(); + let mut inventory = rich_inventory(&completed); + let entries = if duplicate_material { + inventory["materials"].as_array_mut() + } else { + inventory["modules"][0]["contributions"].as_array_mut() + } + .expect("should select records"); + entries.push(entries[0].clone()); + replace(&mut completed, &inventory); + assert!( + completed.retained_contributions().is_err(), + "should reject duplicate ownership" + ); + } +} + +#[test] +fn checked_export_retains_exact_original_raw_bytes() { + let completed = fixture(); + let expected = std::fs::read(completed.directory.path().join("tsjs-inventory.json")) + .expect("should read original bytes"); + let exported = completed + .retained_contributions() + .expect("should export originals"); + assert_eq!( + exported.raw_inventory(), + expected, + "should preserve exact raw bytes" + ); +} diff --git a/crates/trusted-server-release/src/javascript/tests.rs b/crates/trusted-server-release/src/javascript/tests.rs new file mode 100644 index 000000000..a04618184 --- /dev/null +++ b/crates/trusted-server-release/src/javascript/tests.rs @@ -0,0 +1,296 @@ +use super::*; +use serde_json::{Value, json}; +use std::os::unix::fs::symlink; + +mod adopt; + +#[test] +fn one_byte_final_module_difference_rejects_the_complete_comparison() { + let first = tempfile::tempdir().expect("should create A outputs"); + let second = tempfile::tempdir().expect("should create B outputs"); + fs::write(first.path().join("tsjs-core.js"), b"one").expect("should write A module"); + fs::write(second.path().join("tsjs-core.js"), b"one").expect("should write B module"); + let modules = BTreeMap::from([( + "core".to_owned(), + Module { + filename: "tsjs-core.js".to_owned(), + size: 3, + sha256: hex::encode(Sha256::digest(b"one")), + }, + )]); + compare_modules(first.path(), second.path(), &modules, &|| Ok(())) + .expect("should compare complete equal bytes"); + fs::write(second.path().join("tsjs-core.js"), b"onE").expect("should change one final byte"); + assert!( + compare_modules(first.path(), second.path(), &modules, &|| Ok(())).is_err(), + "should reject one differing final byte" + ); +} +#[test] +fn full_suite_report_rejects_zero_selection_or_missing_typecheck_file() { + let expected = BTreeSet::from([ + "test/example.test.ts".to_owned(), + "test/types.test.ts".to_owned(), + ]); + let mut report = json!({"success":true,"numTotalTests":2,"numPassedTests":2,"numFailedTests":0,"numPendingTests":0,"testResults":[{"name":"/example/lib/test/example.test.ts","status":"passed"},{"name":"/example/lib/test/types.test.ts","status":"passed"}]}); + let encode = |value: &Value| serde_json::to_vec(value).expect("should encode test report"); + assert_eq!( + test_report(&encode(&report), &expected, Path::new("/example/lib")) + .expect("should join all registered results"), + 2, + "should retain nonzero passed count" + ); + report["numTotalTests"] = json!(0); + assert!( + test_report(&encode(&report), &expected, Path::new("/example/lib")).is_err(), + "should reject zero selected tests" + ); + report["numTotalTests"] = json!(2); + report["testResults"] + .as_array_mut() + .expect("should retain array") + .pop(); + assert!( + test_report(&encode(&report), &expected, Path::new("/example/lib")).is_err(), + "should reject an omitted registered type-check suite" + ); +} +#[test] +fn full_suite_report_rejects_failed_pending_or_unregistered_results() { + let expected = BTreeSet::from(["test/example.test.ts".to_owned()]); + let passing = json!({"success":true,"numTotalTests":1,"numPassedTests":1,"numFailedTests":0,"numPendingTests":0,"testResults":[{"name":"/example/lib/test/example.test.ts","status":"passed"}]}); + let encode = |value: &Value| serde_json::to_vec(value).expect("should encode test report"); + test_report(&encode(&passing), &expected, Path::new("/example/lib")) + .expect("should accept the passing control report"); + let changes: [(&str, Value); 6] = [ + ("/success", json!(false)), + ("/numFailedTests", json!(1)), + ("/numPassedTests", json!(0)), + ("/numPendingTests", json!(1)), + ("/testResults/0/status", json!("failed")), + ( + "/testResults/0/name", + json!("/example/lib/test/other.test.ts"), + ), + ]; + for (pointer, value) in changes { + let mut report = passing.clone(); + *report + .pointer_mut(pointer) + .expect("should select an existing report field") = value; + assert!( + test_report(&encode(&report), &expected, Path::new("/example/lib")).is_err(), + "should reject a report with changed {pointer}" + ); + } +} +#[test] +fn parsed_inventory_cannot_choose_an_untracked_module_or_filename() { + let mut inventory = json!({"modules":[{"id":"core","filename":"tsjs-core.js","size":1,"sha256":"a".repeat(64)}]}); + let expected = vec!["core".to_owned()]; + let encode = + |value: &Value| serde_json::to_vec(value).expect("should encode inventory fixture"); + inventory_modules(&encode(&inventory), &expected).expect("should parse tracked module facts"); + inventory["modules"][0]["filename"] = json!("../outside"); + assert!( + inventory_modules(&encode(&inventory), &expected).is_err(), + "should reject path-selected bundle output" + ); +} + +fn retained_fixture() -> JsBuildCompleted { + JsBuildCompleted::fixture_for_tests(&"a".repeat(40)) +} + +#[test] +fn retained_module_facts_are_derived_from_the_rehashed_owned_handoff() { + let completed = retained_fixture(); + let modules = completed + .retained_modules() + .expect("should derive checked module facts"); + assert_eq!(modules.len(), 1, "should retain the exact module set"); + assert_eq!( + modules[0].id(), + "core", + "should preserve checked module identity" + ); + assert_eq!( + modules[0].filename(), + "tsjs-core.js", + "should preserve checked filename" + ); + assert_eq!( + modules[0].size(), + b"fictional module".len() as u64, + "should preserve complete module length" + ); + assert_eq!( + modules[0].sha256(), + hex::encode(Sha256::digest(b"fictional module")), + "should preserve complete module digest" + ); + fs::write( + completed.directory.path().join("bundles/tsjs-core.js"), + b"changed", + ) + .expect("should change retained module"); + assert!( + completed.retained_modules().is_err(), + "should reject changed bytes before exporting facts" + ); +} + +#[test] +fn retained_handoff_rejects_extra_root_files() { + let completed = retained_fixture(); + completed + .rehash_for_handoff() + .expect("should rejoin exact fixture bytes"); + fs::write(completed.directory.path().join("unrecorded"), b"extra") + .expect("should inject an extra retained file"); + assert!( + completed.rehash_for_handoff().is_err(), + "should reject unrecorded retained root entries" + ); +} + +#[test] +fn retained_handoff_rejects_identical_directory_aliases() { + for alias_bundles in [false, true] { + let completed = retained_fixture(); + let external = tempfile::tempdir().expect("should create replacement directory"); + let original = if alias_bundles { + completed.directory.path().join("bundles") + } else { + completed.directory.path().to_owned() + }; + let destination = external.path().join("retained"); + fs::rename(&original, &destination).expect("should relocate identical retained bytes"); + symlink(&destination, &original).expect("should alias identical retained directory"); + assert!( + completed.rehash_for_handoff().is_err(), + "should reject directory aliases despite identical bytes" + ); + } +} + +#[test] +fn original_collector_bytes_cannot_be_replaced_by_later_child_output() { + let completed = retained_fixture(); + let output = completed.directory.path(); + let raw = read_bounded_with_check(&output.join("tsjs-inventory.json"), Role::Metadata, &|| { + Ok(()) + }) + .expect("should capture original collector bytes"); + let captured = CapturedInventory { + raw, + modules: completed.modules, + }; + verify_captured_output(output, &captured, &|| Ok(())) + .expect("should rejoin original collector outputs"); + fs::write(output.join("tsjs-inventory.json"), b"replacement inventory") + .expect("should simulate later child inventory replacement"); + assert!( + verify_captured_output(output, &captured, &|| Ok(())).is_err(), + "should reject later inventory replacement before retaining it as original" + ); +} + +#[test] +fn completion_settings_bind_both_actual_boundary_instances() { + let first = settings_digest(&[[1_u8; 32], [2_u8; 32]]); + assert_ne!( + first, + settings_digest(&[[3_u8; 32], [2_u8; 32]]), + "should bind A controls" + ); + assert_ne!( + first, + settings_digest(&[[1_u8; 32], [3_u8; 32]]), + "should bind B controls" + ); + assert_ne!( + first, + settings_digest(&[[2_u8; 32], [1_u8; 32]]), + "should bind comparison-side ordering" + ); +} + +#[test] +fn failed_cleanup_cannot_return_or_retain_a_completed_build() { + let completed = retained_fixture(); + let retained = completed.directory.path().to_owned(); + assert!( + finalize_execution( + Ok(completed), + Err(preparation_error("fictional cleanup failure")) + ) + .is_err(), + "should deny completion when private resource cleanup fails" + ); + assert!( + !retained.exists(), + "should discard private retained output when cleanup fails" + ); + let error = finalize_execution( + Err(preparation_error("fictional execution failure")), + Err(preparation_error("fictional cleanup failure")), + ) + .err() + .expect("should reject both failures"); + let message = format!("{error:?}"); + assert!( + message.contains("fictional execution failure") + && message.contains("fictional cleanup failure"), + "should retain both failure contexts" + ); +} + +#[test] +fn vitest_failure_diagnostics_include_only_bounded_registered_names() { + let report = json!({"testResults":[ + {"name":"/work/lib/test/fictional.test.ts","status":"failed","message":"PRIVATE ASSERTION","assertionResults":[ + {"fullName":"fictional suite should reject invalid input","status":"failed","failureMessages":["PRIVATE STACK"]}, + {"fullName":"successful case","status":"passed"} + ]}, + {"name":"/outside/private-file","status":"failed","assertionResults":[]} + ]}); + let expected = BTreeSet::from(["test/fictional.test.ts".to_owned()]); + let bytes = serde_json::to_vec(&report).expect("should encode fictional report"); + let diagnostic = vitest_failure_diagnostic(&bytes, &expected, Path::new("/work/lib")) + .expect("should parse report"); + assert!( + diagnostic.contains("test/fictional.test.ts") + && diagnostic.contains("fictional suite should reject invalid input"), + "should preserve failed suite and test identities" + ); + for omitted in [ + "PRIVATE ASSERTION", + "PRIVATE STACK", + "successful case", + "private-file", + ] { + assert!( + !diagnostic.contains(omitted), + "should omit arbitrary failure context and unregistered paths" + ); + } + assert!( + vitest_failure_diagnostic(b"{", &expected, Path::new("/work/lib")).is_err(), + "should reject malformed reports" + ); + let tests: Vec<_> = (0..100) + .map(|_| json!({"fullName":format!("{}\n\u{1b}","x".repeat(1000)),"status":"failed"})) + .collect(); + let large = json!({"testResults":[{"name":"/work/lib/test/fictional.test.ts","status":"failed","assertionResults":tests}]}); + let diagnostic = vitest_failure_diagnostic( + &serde_json::to_vec(&large).expect("should encode bounded fixture"), + &expected, + Path::new("/work/lib"), + ) + .expect("should summarize names"); + assert!( + diagnostic.len() < 5000 && !diagnostic.contains(['\n', '\u{1b}']), + "should bound count and length and remove controls" + ); +} diff --git a/crates/trusted-server-release/src/javascript/tests/adopt.rs b/crates/trusted-server-release/src/javascript/tests/adopt.rs new file mode 100644 index 000000000..1ae0198f2 --- /dev/null +++ b/crates/trusted-server-release/src/javascript/tests/adopt.rs @@ -0,0 +1,500 @@ +//! Test-only adoption of the JS handoff an ordinary Linux CI job uploaded. +//! +//! The macOS candidate jobs cannot run the Linux-only two-installation JS +//! build, so they adopt the Linux job's retained handoff as an artifact. +//! Adoption copies exactly `bundles/`, `tsjs-inventory.json` and +//! `npm-observation.json` into a private directory, rejoins the inventory +//! against the authenticated source with the same helpers the real build +//! uses, and requires every module digest and the measurement's inventory +//! digest to match. The artifact itself is not authenticated, and the +//! returned measurement says so. +//! +//! The Linux job exports the artifact as a root holding exactly the +//! retained handoff directory and its measurement beside it. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::super::{JsBuildCompleted, Module, filenames, inventory_modules}; +use crate::documents::{Role, parse_json, read_bounded}; +use crate::error::ReleaseError; +use crate::preparation::{JsAttempt, preparation_error}; +use crate::process::Cancellation; +use crate::source::{ApprovedSource, join_inventory}; + +/// Kind of the Linux job's JS measurement. +const MEASUREMENT_KIND: &str = "ordinary-ci-two-fresh-js-builds"; +/// Origin label of an adopted handoff. +pub(crate) const ADOPTED_ORIGIN: &str = "linux-ordinary-ci-artifact"; +/// How long an adopted handoff stays usable by the candidate run. +const ADOPTED_LIFETIME: Duration = Duration::from_secs(4 * 60 * 60); +/// The exact entries of a retained handoff. +const HANDOFF_ENTRIES: [&str; 3] = ["bundles", "npm-observation.json", "tsjs-inventory.json"]; +/// Directory of an exported root holding the three retained entries. +const EXPORTED_HANDOFF: &str = "handoff"; +/// The Linux job's JS measurement beside the exported handoff. +const EXPORTED_MEASUREMENT: &str = "js-execution-measurement.json"; + +fn lower_hex_64(value: &Value) -> Option<&str> { + value.as_str().filter(|text| { + text.len() == 64 + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +/// The measured facts an adopted handoff must match. +struct MeasuredHandoff<'a> { + inventory_sha256: &'a str, + tools_sha256: &'a str, + settings_sha256: &'a str, + tests: [u64; 2], +} + +/// Requires the Linux job's measurement for `source`. +fn check_measurement<'a>( + source: &ApprovedSource, + measurement: &'a Value, +) -> Result, Report> { + let shape = measurement["schema_version"] == 1 + && measurement["kind"] == MEASUREMENT_KIND + && measurement["release_qualification"] == false; + if !shape { + return Err(preparation_error( + "JS measurement is not an ordinary CI two-build measurement", + )); + } + if measurement["source_commit"].as_str() != Some(source.commit().as_str()) { + return Err(preparation_error( + "JS measurement names another source commit", + )); + } + let digest = |name: &str| { + lower_hex_64(&measurement[name]) + .ok_or_else(|| preparation_error(format!("JS measurement {name} is not a digest"))) + }; + let count = |index: usize| { + measurement["test_counts"][index] + .as_u64() + .filter(|count| *count > 0) + .ok_or_else(|| preparation_error("JS measurement test counts are not positive")) + }; + Ok(MeasuredHandoff { + inventory_sha256: digest("inventory_sha256")?, + tools_sha256: digest("tools_sha256")?, + settings_sha256: digest("settings_sha256")?, + tests: [count(0)?, count(1)?], + }) +} + +/// Reads the exact module set of `bundles` and requires each digest. +fn read_modules( + bundles: &Path, + modules: &BTreeMap, +) -> Result)>, Report> { + let expected: BTreeSet<_> = modules + .values() + .map(|module| module.filename.clone()) + .collect(); + if filenames(bundles, expected.len()).ok() != Some(expected) { + return Err(preparation_error( + "adopted JS bundle set differs from the inventory", + )); + } + modules + .values() + .map(|module| { + let raw = read_bounded(&bundles.join(&module.filename), Role::Bundle)?; + if raw.sha256 != module.sha256 || raw.bytes.len() as u64 != module.size { + return Err(preparation_error( + "adopted JS module differs from the inventory", + )); + } + Ok((module.filename.clone(), raw.bytes)) + }) + .collect() +} + +impl JsBuildCompleted { + /// Exports the retained handoff and its ordinary CI `measurement` into + /// the fresh `destination` for the macOS candidate jobs. + /// + /// The source handoff is rehashed before and after the copy. The caller + /// must read and adopt the export to verify its copied bytes against the + /// measurement before transferring or using it. + /// + /// # Errors + /// + /// Returns an error when the retained root no longer rehashes, + /// `destination` exists or its parent does not, or a copy or the + /// measurement cannot be written. + pub(crate) fn export_ordinary_ci_handoff_for_tests( + &self, + measurement: &Value, + destination: &Path, + ) -> Result<(), Report> { + let retained = self.rehash_for_handoff()?; + fs::create_dir(destination).map_err(preparation_error)?; + let handoff = destination.join(EXPORTED_HANDOFF); + fs::create_dir_all(handoff.join("bundles")).map_err(preparation_error)?; + for name in ["tsjs-inventory.json", "npm-observation.json"] { + fs::copy(retained.join(name), handoff.join(name)).map_err(preparation_error)?; + } + for module in self.modules.values() { + fs::copy( + retained.join("bundles").join(&module.filename), + handoff.join("bundles").join(&module.filename), + ) + .map_err(preparation_error)?; + } + let encoded = serde_json::to_vec_pretty(measurement).map_err(preparation_error)?; + fs::write(destination.join(EXPORTED_MEASUREMENT), encoded).map_err(preparation_error)?; + self.rehash_for_handoff()?; + Ok(()) + } + + /// Reads a root the Linux job exported: exactly the handoff directory and + /// the measurement file, nothing else. + /// + /// Returns the handoff directory and the parsed measurement; neither is + /// authenticated, and [`Self::adopt_ordinary_ci_handoff_for_tests`] + /// checks both. + /// + /// # Errors + /// + /// Returns an error when the root holds any other entry, or the + /// measurement cannot be read or parsed as JSON. + pub(crate) fn read_exported_ordinary_ci_handoff_for_tests( + root: &Path, + ) -> Result<(PathBuf, Value), Report> { + let expected: BTreeSet = [EXPORTED_HANDOFF, EXPORTED_MEASUREMENT] + .map(str::to_owned) + .into(); + if filenames(root, expected.len()).ok() != Some(expected) { + return Err(preparation_error( + "exported JS root holds entries beyond the handoff and its measurement", + )); + } + let raw = read_bounded(&root.join(EXPORTED_MEASUREMENT), Role::Metadata)?; + let measurement = parse_json(&raw.bytes, Role::Metadata)?; + Ok((root.join(EXPORTED_HANDOFF), measurement)) + } + + /// Adopts a Linux ordinary CI JS handoff for a macOS candidate run. + /// + /// Returns the private copy and a measurement labelling its origin as + /// `linux-ordinary-ci-artifact` with `authenticated: false`. + /// + /// # Errors + /// + /// Returns an error when the measurement is not the Linux job's for this + /// source, the handoff holds any entry beyond the three retained ones, + /// the inventory digest differs from the measurement, the inventory does + /// not rejoin the source or name exactly its modules, a module differs + /// from its inventory digest, or the private copy cannot be written. + pub(crate) fn adopt_ordinary_ci_handoff_for_tests( + source: &ApprovedSource, + handoff: &Path, + measurement: &Value, + ) -> Result<(Self, Value), Report> { + let measured = check_measurement(source, measurement)?; + let expected: BTreeSet = HANDOFF_ENTRIES.map(str::to_owned).into(); + if filenames(handoff, expected.len()).ok() != Some(expected) { + return Err(preparation_error( + "adopted JS handoff holds entries beyond the retained three", + )); + } + let inventory = read_bounded(&handoff.join("tsjs-inventory.json"), Role::Metadata)?; + if inventory.sha256 != measured.inventory_sha256 { + return Err(preparation_error( + "adopted JS inventory differs from the measured inventory", + )); + } + join_inventory(source, &inventory.bytes)?; + let modules = inventory_modules(&inventory.bytes, &source.module_ids())?; + let bundles = read_modules(&handoff.join("bundles"), &modules)?; + let observation = read_bounded(&handoff.join("npm-observation.json"), Role::Metadata)?; + + let directory = tempfile::tempdir().map_err(preparation_error)?; + fs::create_dir(directory.path().join("bundles")).map_err(preparation_error)?; + fs::write( + directory.path().join("tsjs-inventory.json"), + &inventory.bytes, + ) + .map_err(preparation_error)?; + fs::write( + directory.path().join("npm-observation.json"), + &observation.bytes, + ) + .map_err(preparation_error)?; + for (filename, bytes) in &bundles { + fs::write(directory.path().join("bundles").join(filename), bytes) + .map_err(preparation_error)?; + } + let adopted = Self { + directory, + source_commit: source.commit().as_str().to_owned(), + tools_sha256: measured.tools_sha256.to_owned(), + settings_sha256: measured.settings_sha256.to_owned(), + inventory_sha256: inventory.sha256.clone(), + observation_sha256: hex::encode(Sha256::digest(&observation.bytes)), + modules, + tests: measured.tests, + attempt: JsAttempt::new(Instant::now() + ADOPTED_LIFETIME, Cancellation::default()), + }; + adopted.rehash_for_handoff()?; + let label = json!({ + "js_handoff": { + "origin": ADOPTED_ORIGIN, + "authenticated": false, + "source_commit": adopted.source_commit, + "inventory_sha256": adopted.inventory_sha256, + "tools_sha256": adopted.tools_sha256, + "settings_sha256": adopted.settings_sha256, + "module_count": adopted.modules.len(), + } + }); + Ok((adopted, label)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::CommitId; + + const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + const OTHER_COMMIT: &str = "fedcba9876543210fedcba9876543210fedcba98"; + const MODULE: &[u8] = b"fictional core module"; + + fn source(commit: &str) -> ApprovedSource { + ApprovedSource::in_memory_for_tests( + &CommitId::parse(commit).expect("should parse the commit"), + &["crates/trusted-server-js/lib/src/core/index.ts"], + ) + } + + fn inventory(commit: &str) -> Vec { + serde_json::to_vec(&json!({ + "schema_version": 1, + "source_commit": commit, + "materials": [], + "tools": [], + "modules": [{ + "id": "core", + "filename": "tsjs-core.js", + "size": MODULE.len(), + "sha256": hex::encode(Sha256::digest(MODULE)), + }], + })) + .expect("should encode the inventory") + } + + /// A retained Linux handoff and its measurement. + fn handoff(commit: &str) -> (tempfile::TempDir, Value) { + let directory = tempfile::tempdir().expect("should create the handoff"); + let root = directory.path(); + fs::create_dir(root.join("bundles")).expect("should create bundles"); + fs::write(root.join("bundles/tsjs-core.js"), MODULE).expect("should write the module"); + let inventory = inventory(commit); + fs::write(root.join("tsjs-inventory.json"), &inventory).expect("should write inventory"); + fs::write(root.join("npm-observation.json"), b"fictional observation") + .expect("should write the observation"); + let measurement = json!({ + "schema_version": 1, + "kind": MEASUREMENT_KIND, + "source_commit": commit, + "test_counts": [3, 3], + "module_count": 1, + "tools_sha256": "b".repeat(64), + "settings_sha256": "c".repeat(64), + "inventory_sha256": hex::encode(Sha256::digest(&inventory)), + "private_roots_removed": 2, + "release_qualification": false, + }); + (directory, measurement) + } + + fn rejection(source: &ApprovedSource, root: &Path, measurement: &Value) -> String { + match JsBuildCompleted::adopt_ordinary_ci_handoff_for_tests(source, root, measurement) { + Ok(_) => panic!("should reject the handoff"), + Err(error) => format!("{error:?}"), + } + } + + #[test] + fn adopted_handoff_is_a_private_rejoined_copy_labelled_unauthenticated() { + let (directory, measurement) = handoff(COMMIT); + + let (adopted, label) = JsBuildCompleted::adopt_ordinary_ci_handoff_for_tests( + &source(COMMIT), + directory.path(), + &measurement, + ) + .expect("should adopt the handoff"); + + let retained = adopted + .rehash_for_handoff() + .expect("should rehash the copy"); + assert_ne!( + retained, + directory.path(), + "should copy into its own directory" + ); + assert_eq!(adopted.source_commit(), COMMIT, "should bind the source"); + assert_eq!(adopted.inventory_sha256(), measurement["inventory_sha256"]); + assert_eq!(adopted.tools_sha256(), "b".repeat(64)); + assert_eq!(adopted.test_counts(), [3, 3]); + assert_eq!(label["js_handoff"]["origin"], "linux-ordinary-ci-artifact"); + assert_eq!(label["js_handoff"]["authenticated"], false); + assert_eq!(label["js_handoff"]["module_count"], 1); + fs::remove_dir_all(directory.path()).expect("should remove the original"); + adopted + .rehash_for_handoff() + .expect("should not depend on the original artifact"); + } + + #[test] + fn exported_handoff_reads_back_and_adopts_unchanged() { + let (directory, measurement) = handoff(COMMIT); + let (adopted, _) = JsBuildCompleted::adopt_ordinary_ci_handoff_for_tests( + &source(COMMIT), + directory.path(), + &measurement, + ) + .expect("should adopt the original handoff"); + let scratch = tempfile::tempdir().expect("should create the export parent"); + let exported = scratch.path().join("js-handoff"); + + adopted + .export_ordinary_ci_handoff_for_tests(&measurement, &exported) + .expect("should export the retained handoff"); + let (handoff_root, read_measurement) = + JsBuildCompleted::read_exported_ordinary_ci_handoff_for_tests(&exported) + .expect("should read the exported root"); + + assert_eq!(handoff_root, exported.join("handoff")); + assert_eq!( + read_measurement, measurement, + "should carry the measurement unchanged" + ); + let (readopted, label) = JsBuildCompleted::adopt_ordinary_ci_handoff_for_tests( + &source(COMMIT), + &handoff_root, + &read_measurement, + ) + .expect("should adopt the exported handoff"); + assert_eq!(readopted.inventory_sha256(), adopted.inventory_sha256()); + assert_eq!(label["js_handoff"]["authenticated"], false); + assert!( + adopted + .export_ordinary_ci_handoff_for_tests(&measurement, &exported) + .is_err(), + "should refuse an existing destination" + ); + } + + #[test] + fn exported_roots_with_extra_or_missing_entries_are_rejected() { + let scratch = tempfile::tempdir().expect("should create the export root"); + let root = scratch.path(); + fs::create_dir(root.join("handoff")).expect("should create the handoff"); + assert!( + JsBuildCompleted::read_exported_ordinary_ci_handoff_for_tests(root).is_err(), + "should reject a root without its measurement" + ); + fs::write(root.join("js-execution-measurement.json"), b"{}") + .expect("should write the measurement"); + JsBuildCompleted::read_exported_ordinary_ci_handoff_for_tests(root) + .expect("should read an exact root"); + fs::write(root.join("extra.json"), b"{}").expect("should add an entry"); + let error = JsBuildCompleted::read_exported_ordinary_ci_handoff_for_tests(root) + .expect_err("should reject an extra entry"); + assert!( + format!("{error:?}").contains("beyond the handoff and its measurement"), + "should name the unexpected entry: {error:?}" + ); + fs::remove_file(root.join("extra.json")).expect("should remove the extra entry"); + fs::write(root.join("js-execution-measurement.json"), b"not json") + .expect("should corrupt the measurement"); + assert!( + JsBuildCompleted::read_exported_ordinary_ci_handoff_for_tests(root).is_err(), + "should reject a measurement that is not JSON" + ); + } + + #[test] + fn handoffs_with_extra_or_changed_entries_are_rejected() { + let (directory, measurement) = handoff(COMMIT); + fs::write(directory.path().join("extra.json"), b"{}").expect("should add an entry"); + assert!( + rejection(&source(COMMIT), directory.path(), &measurement) + .contains("beyond the retained three"), + "should reject an extra root entry" + ); + + let (directory, measurement) = handoff(COMMIT); + fs::write(directory.path().join("bundles/tsjs-extra.js"), b"x").expect("should add"); + assert!( + rejection(&source(COMMIT), directory.path(), &measurement) + .contains("bundle set differs"), + "should reject an extra bundle" + ); + + let (directory, measurement) = handoff(COMMIT); + fs::write( + directory.path().join("bundles/tsjs-core.js"), + b"fictional core modulE", + ) + .expect("should change the module"); + assert!( + rejection(&source(COMMIT), directory.path(), &measurement) + .contains("module differs from the inventory"), + "should reject a module digest mismatch" + ); + } + + #[test] + fn handoffs_from_another_commit_or_measurement_are_rejected() { + let (directory, measurement) = handoff(OTHER_COMMIT); + assert!( + rejection(&source(COMMIT), directory.path(), &measurement) + .contains("another source commit"), + "should reject a measurement of a foreign commit" + ); + + let (directory, mut measurement) = handoff(OTHER_COMMIT); + measurement["source_commit"] = json!(COMMIT); + let inventory = fs::read(directory.path().join("tsjs-inventory.json")) + .expect("should read the inventory"); + measurement["inventory_sha256"] = json!(hex::encode(Sha256::digest(&inventory))); + assert!( + rejection(&source(COMMIT), directory.path(), &measurement) + .contains("does not match the approved commit"), + "should reject an inventory of a foreign commit" + ); + + let (directory, mut measurement) = handoff(COMMIT); + measurement["inventory_sha256"] = json!("d".repeat(64)); + assert!( + rejection(&source(COMMIT), directory.path(), &measurement) + .contains("differs from the measured inventory"), + "should reject an inventory the measurement did not record" + ); + + let (directory, mut measurement) = handoff(COMMIT); + measurement["release_qualification"] = json!(true); + assert!( + rejection(&source(COMMIT), directory.path(), &measurement) + .contains("not an ordinary CI two-build measurement"), + "should reject a measurement claiming qualification" + ); + } +} diff --git a/crates/trusted-server-release/src/lifecycle_guard.rs b/crates/trusted-server-release/src/lifecycle_guard.rs new file mode 100644 index 000000000..2262a4176 --- /dev/null +++ b/crates/trusted-server-release/src/lifecycle_guard.rs @@ -0,0 +1,86 @@ +//! Linux-only N-API guard for the trusted npm lifecycle observer. +//! +//! Compile this approved source with the selected native Rust toolchain using +//! `--crate-type cdylib --edition 2024`. The resulting `.node` file is a measured +//! tool input, loaded before the observer receives its private authentication +//! key. It is not a standalone executable or a qualification receipt. + +#![deny(unsafe_op_in_unsafe_fn)] + +#[cfg(not(target_os = "linux"))] +compile_error!("the npm lifecycle guard requires Linux"); + +use core::ffi::{c_char, c_int, c_ulong, c_void}; +use core::ptr; + +const PR_SET_DUMPABLE: c_int = 4; +const PR_GET_DUMPABLE: c_int = 3; + +unsafe extern "C" { + fn prctl(option: c_int, ...) -> c_int; + fn umask(mask: u32) -> u32; + fn napi_throw_error(env: *mut c_void, code: *const c_char, message: *const c_char) -> c_int; + fn napi_get_boolean(env: *mut c_void, value: bool, result: *mut *mut c_void) -> c_int; + fn napi_set_named_property( + env: *mut c_void, + object: *mut c_void, + name: *const c_char, + value: *mut c_void, + ) -> c_int; +} + +/// Protects the observer from same-user process-memory and `/proc` descriptor +/// access before it accepts sensitive input. +/// +/// # Safety +/// +/// Node must call this N-API registration entry with a live environment and +/// exports object. The library must come from the selected read-only tool root. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn napi_register_module_v1( + env: *mut c_void, + exports: *mut c_void, +) -> *mut c_void { + // SAFETY: umask accepts any mode mask. This dedicated observer process + // selects npm's fixed extraction mode before spawning any install hooks. + unsafe { + umask(0o022); + } + // SAFETY: Linux prctl accepts these fixed options and zero arguments. + let protected = unsafe { + prctl( + PR_SET_DUMPABLE, + 0 as c_ulong, + 0 as c_ulong, + 0 as c_ulong, + 0 as c_ulong, + ) == 0 + && prctl( + PR_GET_DUMPABLE, + 0 as c_ulong, + 0 as c_ulong, + 0 as c_ulong, + 0 as c_ulong, + ) == 0 + }; + let mut value = ptr::null_mut(); + // SAFETY: Node supplies valid N-API handles; all strings are static C strings. + let exported = protected + && unsafe { + napi_get_boolean(env, true, &mut value) == 0 + && napi_set_named_property(env, exports, c"protected".as_ptr(), value) == 0 + }; + if !exported { + // SAFETY: The live Node environment and static NUL-terminated message + // meet N-API's preconditions. A pending exception rejects require(). + unsafe { + napi_throw_error( + env, + ptr::null(), + c"Cannot protect lifecycle observer".as_ptr(), + ); + } + return ptr::null_mut(); + } + exports +} diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index 0b0ec7aeb..eb018f9ac 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -1,22 +1,210 @@ -// These reviewed primitives have no release command until their callers land. -#[allow(dead_code, reason = "release command assembly is staged in Phase 2")] +// consumed by Task 7 preparation and Task 9 recipes +#[allow(dead_code)] +mod boundary; +#[allow(dead_code, reason = "Task 10 composition capture is being integrated")] +mod composition; +// Atomic output and schema helpers are consumed by later producer stages. +#[allow(dead_code)] mod documents; mod error; -#[allow(dead_code, reason = "source closure caller is staged in Phase 2")] mod git_objects; -#[allow(dead_code, reason = "inspection recipe callers are staged in Phase 2")] +#[allow(dead_code, reason = "consumed by Task 9 recipes")] mod inspect; +#[allow(dead_code, reason = "production JS tool qualification remains closed")] +mod javascript; +#[allow(dead_code, reason = "consumed by Task 7 material preparation")] +mod materials; #[allow(dead_code, reason = "ordinary adapter command assembly is pending")] mod ordinary; -#[allow(dead_code, reason = "confined execution callers are staged in Phase 2")] +mod package; +mod policy; +mod preflight; +#[allow(dead_code, reason = "production JS tool qualification remains closed")] +mod preparation; +#[allow( + dead_code, + reason = "Task 9 slice 1 recipe layer has no production caller yet" +)] +mod recipe; +// `expect` would be unfulfilled in the test build, where the tests use it. +#[allow(dead_code, reason = "consumed by Task 7 boundary and preparation")] mod process; -#[allow(dead_code, reason = "tool tree recipe callers are staged in Phase 2")] +#[allow(dead_code)] +mod source; mod tool_tree; +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; +use source::CommitId; + +/// Exit status of `audit-inputs` when any input is incomplete. +const INCOMPLETE_STATUS: u8 = 3; + +#[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 { + /// Fail-closed release preflight over the current-directory source root. + Preflight { + #[arg(long)] + cohort: PathBuf, + #[arg(long = "source-commit")] + source_commit: String, + #[arg(long = "require-qualified")] + require_qualified: bool, + }, + /// Diagnostic report of pending inputs; constructs no authorization. + AuditInputs, + /// 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, + }, + /// Private fixed in-image GNU tool measurement; supplies no authority. + #[command(hide = true)] + GnuImageMeasurement, + /// Network-denial socket probe used by the boundary controls. + BoundaryProbe { + #[command(subcommand)] + probe: ProbeCommand, + }, +} + +#[derive(Subcommand)] +enum ProbeCommand { + /// Attempts one TCP connect to a literal loopback `IP:PORT`, prints + /// `connected` (exit 0) or `denied errno=` (exit 3), and does nothing + /// else. + Connect { address: 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 _ = writeln!(std::io::stderr(), "{}", error::ReleaseError::Usage); - ExitCode::FAILURE + let inherited: Vec<(OsString, OsString)> = std::env::vars_os().collect(); + Report::set_color_mode(ColorMode::None); + let arguments: Vec = std::env::args_os().collect(); + match 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::Preflight { + cohort, + source_commit, + require_qualified, + } => { + let commit = CommitId::parse(&source_commit)?; + preflight::run_preflight(inherited, &cohort, &commit, require_qualified)?; + Ok(ExitCode::SUCCESS) + } + Command::AuditInputs => audit_inputs(), + 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) + } + Command::GnuImageMeasurement => { + let bytes = recipe::gnu::helper_measurement()?; + std::io::stdout().write_all(&bytes).map_err(|error| { + Report::new(ReleaseError::Recipe) + .attach(format!("cannot write GNU measurement: {error}")) + })?; + Ok(ExitCode::SUCCESS) + } + Command::BoundaryProbe { + probe: ProbeCommand::Connect { address }, + } => boundary_probe(&address), + } +} + +/// Runs the single-connect probe and reports its real outcome. +fn boundary_probe(address: &str) -> Result> { + let address = boundary::parse_loopback(address)?; + let report = boundary::connect_probe(address)?; + std::io::stdout() + .write_all(format!("{report}\n").as_bytes()) + .map_err(|error| { + Report::new(ReleaseError::Boundary) + .attach(format!("cannot write probe report: {error}")) + })?; + Ok(match report { + boundary::ProbeReport::Connected => ExitCode::SUCCESS, + boundary::ProbeReport::Denied { .. } => ExitCode::from(boundary::DENIED_STATUS), + }) +} + +fn audit_inputs() -> Result> { + let root = preflight::source_root()?; + let inputs = policy::load_inputs(&root)?; + let audit = policy::audit_inputs(&inputs); + let bytes = documents::canonical_json(&audit.to_json(), documents::Role::Metadata)?; + std::io::stdout().write_all(&bytes).map_err(|error| { + Report::new(ReleaseError::Incomplete).attach(format!("cannot write audit report: {error}")) + })?; + if audit.is_complete() { + return Ok(ExitCode::SUCCESS); + } + notice(format_args!( + "{}: release inputs are incomplete; this diagnostic authorizes nothing", + ReleaseError::Incomplete + )); + Ok(ExitCode::from(INCOMPLETE_STATUS)) } diff --git a/crates/trusted-server-release/src/materials.rs b/crates/trusted-server-release/src/materials.rs new file mode 100644 index 000000000..5ae8d14ef --- /dev/null +++ b/crates/trusted-server-release/src/materials.rs @@ -0,0 +1,277 @@ +//! Original dependency identities derived from the independently approved source. +//! +//! Lockfile checksums authenticate original archive bytes. They do not prove +//! extraction, installation, lifecycle execution, tool qualification or JS +//! build completion. + +mod archive; +mod cargo_vendor; +mod extract; +mod git; +mod locked; +mod manifest; +mod npm_bundle; + +use std::collections::BTreeMap; +use std::path::Path; +use std::time::Instant; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::documents::Role; +use crate::error::ReleaseError; +use crate::process::Cancellation; +use crate::source::ApprovedSource; +pub(crate) use archive::VerifiedOriginal; +#[cfg(all(test, target_os = "linux"))] +pub(crate) use cargo_vendor::resolution_differences; +#[allow(unused_imports, reason = "consumed by the Task 9 recipe")] +pub(crate) use cargo_vendor::{ + CargoOrigin, GitSourceConfig, GitVendorInput, PreparedCargoMaterials, VendorInputs, + VerifiedCargoPackage, +}; +pub(crate) use extract::ExtractedOriginal; +use locked::{CargoMaterials, NpmMaterial, parse_cargo, parse_npm}; +pub(crate) use manifest::{ReviewedNpmManifest, RootManifestReview}; +pub(crate) use npm_bundle::PreparedNpmOriginals; + +const NPM_LOCK: &str = "crates/trusted-server-js/lib/package-lock.json"; + +fn material_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Material).attach(detail.to_string()) +} + +/// Expected original dependency identities, bound to approved lockfile bytes. +/// +/// This is not a completed installation or a qualified tool selection. +pub(crate) struct LockedMaterials { + source_commit: String, + cargo_lock_sha256: String, + npm_lock_sha256: String, + cargo: CargoMaterials, + npm: BTreeMap, +} + +impl LockedMaterials { + /// Starts one bounded collection of locked Git object snapshots. + /// + /// This selects a local executable; it does not qualify a release tool. + /// + /// # Errors + /// + /// Returns an error for an unsafe executable or expired collection. + pub(crate) fn git_collection( + &self, + program: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result, Report> { + git::GitCollection::new(self, program, deadline, cancellation) + } + /// Reviews approved root declarations without executing npm lifecycle hooks. + /// + /// # Errors + /// + /// Returns an error for changed source, lifecycle hooks or an implicit install. + pub(crate) fn review_root( + &self, + source: &ApprovedSource, + ) -> Result> { + if source.commit().as_str() != self.source_commit { + return Err(material_error( + "root manifest is bound to a different approved source", + )); + } + manifest::review_root(source) + } + + /// Reviews a verified npm manifest against locked identity and hook policy. + /// + /// # Errors + /// + /// Returns an error for a mismatched original, changed manifest or unreviewed hook. + pub(crate) fn review_npm( + &self, + location: &str, + extracted: ExtractedOriginal, + ) -> Result> { + let locked = self + .npm + .get(location) + .ok_or_else(|| material_error("unknown npm location"))?; + if extracted.source_commit() != self.source_commit + || extracted.identity() != format!("npm:{location}") + { + return Err(material_error( + "extracted manifest is not bound to this locked acquisition", + )); + } + manifest::review_npm(extracted, &locked.name, &locked.version) + } + /// Extracts a verified npm original into new private state. + /// + /// # Errors + /// + /// Returns a material error if the original is not bound to this source + /// and locked location, or its archive violates the extraction policy. + pub(crate) fn extract_npm( + &self, + location: &str, + original: VerifiedOriginal, + ) -> Result> { + let locked = self + .npm + .get(location) + .ok_or_else(|| material_error("unknown npm location"))?; + self.join_original(&original, &format!("npm:{location}"))?; + let index_alias = matches!( + (locked.name.as_str(), locked.version.as_str()), + ("agent-base", "7.1.4") + | ("http-proxy-agent", "7.0.2") + | ("https-proxy-agent", "7.0.6") + ); + let (root, node_tar_timestamps) = match (locked.name.as_str(), locked.version.as_str()) { + ("@types/chai", "5.2.3") => ("chai", true), + ("@types/deep-eql", "4.0.2") => ("deep-eql", true), + ("@types/estree", "1.0.8") => ("estree", true), + ("@types/jsdom", "27.0.0") => ("jsdom", true), + ("@types/json-schema", "7.0.15") => ("json-schema", true), + ("@types/json5", "0.0.29") => ("json5", false), + ("@types/node", "24.10.13") => ("node v24.10", true), + ("@types/tough-cookie", "4.0.5") => ("tough-cookie", true), + _ => ("package", false), + }; + extract::extract( + original, + extract::Selection { + root, + index_alias, + node_tar_timestamps, + local_pax: locked.name == "gulp-wrap" && locked.version == "0.15.0", + cargo_package: false, + }, + ) + } + + /// Extracts a verified registry crate under its exact locked root. + /// + /// # Errors + /// + /// Returns a material error for a mismatched identity or unsafe archive. + pub(crate) fn extract_registry( + &self, + name: &str, + version: &str, + original: VerifiedOriginal, + ) -> Result> { + if !self + .cargo + .registry + .contains_key(&(name.to_owned(), version.to_owned())) + { + return Err(material_error("unknown registry identity")); + } + self.join_original(&original, &format!("crate:{name}@{version}"))?; + extract::extract( + original, + extract::Selection { + root: &format!("{name}-{version}"), + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: true, + }, + ) + } + + fn join_original( + &self, + original: &VerifiedOriginal, + identity: &str, + ) -> Result<(), Report> { + if original.source_commit() != self.source_commit || original.identity() != identity { + return Err(material_error( + "original is not bound to this approved locked identity", + )); + } + Ok(()) + } + + /// Loads only the approved tracked lockfiles and derives their identities. + /// + /// # Errors + /// + /// Returns a material/source/document error if either lockfile changed, + /// has an unsupported format or cannot identify an original acquisition. + pub(crate) fn from_source(source: &ApprovedSource) -> Result> { + let cargo = source.read_tracked("Cargo.lock", Role::Metadata)?; + let npm = source.read_tracked(NPM_LOCK, Role::Metadata)?; + Ok(Self { + source_commit: source.commit().as_str().to_owned(), + cargo_lock_sha256: hex::encode(Sha256::digest(&cargo)), + npm_lock_sha256: hex::encode(Sha256::digest(&npm)), + cargo: parse_cargo(&cargo)?, + npm: parse_npm(&npm)?, + }) + } + + /// Verifies an original npm archive selected by its locked installation path. + /// + /// Optional packages receive the same checksum checks as every other package. + /// + /// # Errors + /// + /// Returns a material error for an unknown location or changed archive. + pub(crate) fn verify_npm( + &self, + location: &str, + path: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let locked = self + .npm + .get(location) + .ok_or_else(|| material_error("npm archive is not in the approved lockfile"))?; + archive::verify( + path, + archive::Expected::Sha512(&locked.sha512), + &self.source_commit, + &format!("npm:{location}"), + deadline, + cancellation, + ) + } + + /// Verifies an original registry crate selected by its exact locked identity. + /// + /// # Errors + /// + /// Returns a material error for an unknown identity or changed archive. + pub(crate) fn verify_registry( + &self, + name: &str, + version: &str, + path: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let locked = self + .cargo + .registry + .get(&(name.to_owned(), version.to_owned())) + .ok_or_else(|| material_error("crate archive is not in the approved lockfile"))?; + archive::verify( + path, + archive::Expected::Sha256(&locked.sha256), + &self.source_commit, + &format!("crate:{name}@{version}"), + deadline, + cancellation, + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/materials/archive.rs b/crates/trusted-server-release/src/materials/archive.rs new file mode 100644 index 000000000..fde4a02a5 --- /dev/null +++ b/crates/trusted-server-release/src/materials/archive.rs @@ -0,0 +1,202 @@ +//! Checks original bytes and retains a private copy, without extracting them. + +use std::fs::{File, Metadata, OpenOptions}; +use std::io::{Read as _, Seek as _, Write}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::Path; +use std::time::Instant; + +use error_stack::Report; +use sha2::{Digest as _, Sha256, Sha512}; + +use super::material_error; +use crate::error::ReleaseError; +use crate::process::Cancellation; + +/// Original dependency archives are distinct from toolchain distributions. +/// The current npm/crate material stage accepts at most 64 MiB per original. +const ARCHIVE_LIMIT: u64 = 64 * 1024 * 1024; + +#[derive(Clone, Copy)] +pub(super) enum Expected<'a> { + Sha256(&'a str), + Sha512(&'a [u8; 64]), +} + +fn stamp(metadata: &Metadata) -> (u64, u64, u64, u64, i64, i64, i64, i64) { + ( + metadata.dev(), + metadata.ino(), + metadata.nlink(), + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) +} + +/// Exact verified original bytes, retained independently of their input path. +/// +/// No deserialization or caller-supplied digest constructor is available. +pub(crate) struct VerifiedOriginal { + file: File, + source_commit: String, + identity: String, + sha256: String, + length: u64, + deadline: Instant, + cancellation: Cancellation, +} + +fn check_limits( + deadline: Instant, + cancellation: &Cancellation, +) -> Result<(), Report> { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(material_error( + "original material verification cancelled or timed out", + )); + } + Ok(()) +} + +impl VerifiedOriginal { + pub(super) fn check(&self) -> Result<(), Report> { + check_limits(self.deadline, &self.cancellation) + } + + /// Copies the retained original after checking its exact bytes again. + /// + /// # Errors + /// + /// Returns a material error if retained bytes changed or copying fails. + pub(crate) fn copy_verified( + &mut self, + destination: &mut impl Write, + ) -> Result<(), Report> { + check_limits(self.deadline, &self.cancellation)?; + self.file.rewind().map_err(material_error)?; + let mut hasher = Sha256::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + check_limits(self.deadline, &self.cancellation)?; + let count = self.file.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + length += count as u64; + if length > self.length { + return Err(material_error("retained original grew")); + } + hasher.update(&buffer[..count]); + } + if length != self.length || hex::encode(hasher.finalize()) != self.sha256 { + return Err(material_error("retained original changed")); + } + self.file.rewind().map_err(material_error)?; + let mut copied = 0_u64; + loop { + check_limits(self.deadline, &self.cancellation)?; + let count = self.file.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + copied += count as u64; + if copied > self.length { + return Err(material_error("retained original grew while copying")); + } + destination + .write_all(&buffer[..count]) + .map_err(material_error)?; + } + if copied != self.length { + return Err(material_error("retained original changed while copying")); + } + check_limits(self.deadline, &self.cancellation)?; + Ok(()) + } + + /// Returns the approved source that supplies this archive's expected identity. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } + + /// Returns the original's source-bound package or installation identity. + pub(crate) fn identity(&self) -> &str { + &self.identity + } + + /// Returns the SHA-256 of the exact retained distribution bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } +} + +pub(super) fn verify( + path: &Path, + expected: Expected<'_>, + source_commit: &str, + identity: &str, + deadline: Instant, + cancellation: &Cancellation, +) -> Result> { + check_limits(deadline, cancellation)?; + let mut input = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(material_error)?; + let before = input.metadata().map_err(material_error)?; + if !before.is_file() || before.nlink() != 1 || before.len() > ARCHIVE_LIMIT { + return Err(material_error( + "original must be a bounded regular single-link file", + )); + } + let mut file = tempfile::tempfile().map_err(material_error)?; + let mut sha256 = Sha256::new(); + let mut sha512 = Sha512::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + check_limits(deadline, cancellation)?; + let count = input.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + length += count as u64; + if length > ARCHIVE_LIMIT { + return Err(material_error("original exceeds archive limit")); + } + sha256.update(&buffer[..count]); + sha512.update(&buffer[..count]); + file.write_all(&buffer[..count]).map_err(material_error)?; + } + if length != before.len() || stamp(&before) != stamp(&input.metadata().map_err(material_error)?) + { + return Err(material_error( + "original changed while reading its held descriptor", + )); + } + let digest = hex::encode(sha256.finalize()); + let matches = match expected { + Expected::Sha256(wanted) => digest == wanted, + Expected::Sha512(wanted) => sha512.finalize()[..] == wanted[..], + }; + if !matches { + return Err(material_error( + "original archive differs from its approved lockfile identity", + )); + } + check_limits(deadline, cancellation)?; + Ok(VerifiedOriginal { + file, + source_commit: source_commit.to_owned(), + identity: identity.to_owned(), + sha256: digest, + length, + deadline, + cancellation: cancellation.clone(), + }) +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor.rs b/crates/trusted-server-release/src/materials/cargo_vendor.rs new file mode 100644 index 000000000..d44df109a --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor.rs @@ -0,0 +1,629 @@ +//! Cargo directory-source vendoring from verified originals. +//! +//! Registry crates come from archives already authenticated against the +//! approved `Cargo.lock` checksum and extracted under the archive policy. +//! Git packages come from a verified object closure. Each registry crate +//! becomes `vendor/-/` and each Git package `vendor//` +//! (so sibling paths inside its own workspace still resolve), with a +//! producer-written `.cargo-checksum.json` (see [`writer`]). The result is a +//! [`PreparedCargoMaterials`] token whose digest covers the whole vendor tree. +//! +//! # Normalized Git manifests +//! +//! A Git package whose manifest inherits from its workspace +//! (`version.workspace = true`) cannot be used from a directory source as it +//! is. The producer resolves that inheritance itself (see [`normalize`]) from +//! the package manifest and the enclosing workspace root manifest of the same +//! verified tree, writes the result as `Cargo.toml` and keeps the committed +//! manifest as `Cargo.toml.orig`. No caller-supplied manifest is accepted. +//! The normalization is a reviewed producer transformation: its digests are +//! recorded in [`PreparedCargoMaterials::normalized_manifest_digests`] and do +//! not by themselves make a build unqualified. + +#[cfg(test)] +mod differential; +mod git_tree; +mod index; +mod normalize; +mod source; +mod writer; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, DirBuilder}; +use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::git::VerifiedGitMaterial; +use super::locked::CARGO_REGISTRY; +use super::locked::GitPackage; +use super::{LockedMaterials, material_error}; +use crate::error::ReleaseError; +use crate::process::Cancellation; +use crate::tool_tree::tool_tree_digest; +#[cfg(all(test, target_os = "linux"))] +pub(crate) use differential::resolution_differences; +use git_tree::{Store, ancestor_manifests, find_package, materialize}; +pub(crate) use index::{CargoOrigin, VerifiedCargoPackage}; +use normalize::{PackageManifest, check_target_paths, normalize_manifest}; +pub(crate) use source::GitSourceConfig; +use writer::PackageWriter; + +/// Name of the directory below the materials root that holds every package. +pub(crate) const VENDOR_DIRECTORY: &str = "vendor"; +/// Largest manifest parsed. +const MANIFEST_LIMIT: u64 = 1024 * 1024; +/// Largest single vendored file. +const FILE_LIMIT: u64 = 128 * 1024 * 1024; +/// Most files copied from one extracted crate. +const FILE_COUNT_LIMIT: usize = 100_000; + +/// A verified Git closure for one locked Git package. +/// +/// It carries no manifest: every vendored manifest comes from the verified +/// tree, normalized by the producer when it inherits from its workspace. +pub(crate) struct GitVendorInput<'a> { + /// The verified closure for the package's locked source and revision. + pub(crate) material: &'a VerifiedGitMaterial, + /// Locked package name. + pub(crate) name: &'a str, + /// Locked package version. + pub(crate) version: &'a str, +} + +/// Every original the vendor tree is prepared from. +pub(crate) struct VendorInputs<'a> { + /// Registry archive paths by exact locked `(name, version)`. + pub(crate) registry_archives: &'a BTreeMap<(String, String), PathBuf>, + /// One verified closure per locked Git package. + pub(crate) git: &'a [GitVendorInput<'a>], +} + +/// A prepared, digest-bound vendor tree. +/// +/// Only [`LockedMaterials::prepare_cargo_vendor`] constructs it outside tests. +#[derive(Debug)] +pub(crate) struct PreparedCargoMaterials { + root: PathBuf, + vendor: PathBuf, + digest: String, + source_commit: String, + cargo_lock_sha256: String, + git_sources: Vec, + normalized_manifests: BTreeMap, + derived_manifests: BTreeMap, + packages: BTreeMap, +} + +impl PreparedCargoMaterials { + /// Verified original identities, keyed by the prepared vendor directory. + /// + /// This includes prepared but uncompiled dependencies. Observed compiler + /// units must be joined separately before claiming artifact membership. + pub(crate) fn packages(&self) -> &BTreeMap { + &self.packages + } + + /// The materials root that holds only the vendor directory. + pub(crate) fn root(&self) -> &Path { + &self.root + } + + /// The directory-source root passed to Cargo. + pub(crate) fn vendor_dir(&self) -> &Path { + &self.vendor + } + + /// Digest of every vendored path, mode, size and content. + pub(crate) fn digest(&self) -> &str { + &self.digest + } + + /// The approved source commit whose lock selected these originals. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } + + /// SHA-256 of the approved raw `Cargo.lock`. + pub(crate) fn cargo_lock_sha256(&self) -> &str { + &self.cargo_lock_sha256 + } + + /// Distinct locked Git sources, sorted by key, for source replacement. + pub(crate) fn git_sources(&self) -> &[GitSourceConfig] { + &self.git_sources + } + + /// Digests of the manifests the producer normalized, by vendor directory. + /// + /// Each was derived by [`normalize`] from the verified tree alone, a + /// reviewed transformation; it does not make the build unqualified. + pub(crate) fn normalized_manifest_digests(&self) -> &BTreeMap { + &self.normalized_manifests + } + + /// Digests of caller-derived manifests, by vendor directory. + /// + /// [`LockedMaterials::prepare_cargo_vendor`] accepts no caller-supplied + /// manifest, so it always leaves this empty. It stays part of the token + /// so any caller-derived manifest, such as one a test constructs, still + /// marks the build unqualified. + pub(crate) fn derived_manifest_digests(&self) -> &BTreeMap { + &self.derived_manifests + } + + /// Recomputes the digest and requires the root to hold only the vendor tree. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] when anything under the root was + /// edited, added, removed or linked since preparation. + pub(crate) fn rehash(&self) -> Result<(), Report> { + let names = fs::read_dir(&self.root) + .map_err(material_error)? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::, _>>() + .map_err(material_error)?; + if names != [std::ffi::OsString::from(VENDOR_DIRECTORY)] { + return Err(material_error( + "materials root holds something other than the vendor directory", + )); + } + if tool_tree_digest(&self.vendor, &|| Ok(()))? != self.digest { + return Err(material_error( + "vendored materials changed after preparation", + )); + } + Ok(()) + } + + /// Records a derived manifest digest on a test token. + #[cfg(test)] + #[must_use] + pub(crate) fn with_derived_for_tests(mut self, directory: &str, digest: &str) -> Self { + self.derived_manifests + .insert(directory.to_owned(), digest.to_owned()); + self + } + + /// Records a producer-normalized manifest digest on a test token. + #[cfg(test)] + #[must_use] + pub(crate) fn with_normalized_for_tests(mut self, directory: &str, digest: &str) -> Self { + self.normalized_manifests + .insert(directory.to_owned(), digest.to_owned()); + self + } + + /// Builds a token over an existing `root/vendor`, for tests only. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] when the vendor tree cannot be + /// digested or a selector is not a supported Git source. + #[cfg(test)] + pub(crate) fn for_tests( + root: &Path, + source_commit: &str, + git_selectors: &[&str], + ) -> Result> { + let vendor = root.join(VENDOR_DIRECTORY); + match fs::create_dir(&vendor) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(material_error(error)), + } + Ok(Self { + root: root.to_owned(), + digest: tool_tree_digest(&vendor, &|| Ok(()))?, + vendor, + source_commit: source_commit.to_owned(), + cargo_lock_sha256: "b".repeat(64), + git_sources: git_selectors + .iter() + .map(|selector| GitSourceConfig::parse(selector)) + .collect::>()?, + normalized_manifests: BTreeMap::new(), + derived_manifests: BTreeMap::new(), + packages: BTreeMap::new(), + }) + } +} + +fn attempt_check( + deadline: Instant, + cancellation: &Cancellation, +) -> Result<(), Report> { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(material_error("Cargo vendoring cancelled or timed out")); + } + Ok(()) +} + +/// Requires a vendor directory name that cannot escape or hide. +fn plain_directory(directory: String) -> Result> { + let plain = !directory.is_empty() + && !directory.starts_with('.') + && directory + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'+')); + if !plain { + return Err(material_error(format!( + "package identity {directory:?} is not a safe vendor directory name" + ))); + } + Ok(directory) +} + +/// Names a registry crate's vendor directory `-`. +fn directory_name(name: &str, version: &str) -> Result> { + plain_directory(format!("{name}-{version}")) +} + +/// Names a Git package's vendor directory after the package alone. +/// +/// This is the layout `cargo vendor` gives a unique name, and it keeps paths +/// between sibling members of the package's own workspace resolving: a +/// package that reads `..//...` from its manifest directory, as a +/// checkout allows, finds that sibling in the vendor directory too. A +/// registry crate or second Git package with the same directory name still +/// fails closed, because every vendor directory is created exclusively. +fn git_directory_name(name: &str) -> Result> { + plain_directory(name.to_owned()) +} + +/// Requires a real, empty directory that no symlink aliases. +fn check_output_root(root: &Path) -> Result<(), Report> { + let metadata = fs::symlink_metadata(root).map_err(material_error)?; + let resolved = fs::canonicalize(root).map_err(material_error)?; + if !metadata.is_dir() || resolved.as_os_str() != root.as_os_str() { + return Err(material_error( + "materials root is not a real unaliased directory", + )); + } + if fs::read_dir(root).map_err(material_error)?.next().is_some() { + return Err(material_error("materials root already has entries")); + } + Ok(()) +} + +/// Parses a manifest of at most one MiB into a TOML table. +fn parse_manifest(bytes: &[u8]) -> Result> { + if bytes.len() as u64 > MANIFEST_LIMIT { + return Err(material_error("package manifest exceeds the size limit")); + } + let text = std::str::from_utf8(bytes).map_err(material_error)?; + toml::from_str(text).map_err(material_error) +} + +/// Returns the declared `(name, version)`; the version is `None` when it is +/// not a plain string (inherited from a workspace). +fn declared_identity(table: &toml::Table) -> Option<(String, Option)> { + let package = table.get("package")?; + let name = package.get("name")?.as_str()?.to_owned(); + let version = package + .get("version") + .and_then(toml::Value::as_str) + .map(str::to_owned); + Some((name, version)) +} + +/// Reports whether any table below `value` has `workspace = true`. +fn inherits_workspace(value: &toml::Value) -> bool { + match value { + toml::Value::Table(table) => { + table.get("workspace") == Some(&toml::Value::Boolean(true)) + || table.values().any(inherits_workspace) + } + toml::Value::Array(values) => values.iter().any(inherits_workspace), + _ => false, + } +} + +/// Requires a plain-string identity equal to the locked one. +fn require_identity( + manifest: &[u8], + name: &str, + version: &str, +) -> Result<(), Report> { + let table = parse_manifest(manifest)?; + match declared_identity(&table) { + Some((found_name, Some(found_version))) + if found_name == name && found_version == version => + { + Ok(()) + } + _ => Err(material_error(format!( + "manifest does not declare package {name} {version}" + ))), + } +} + +/// Copies every file of an extracted crate into `writer`. +fn copy_extracted(root: &Path, writer: &mut PackageWriter) -> Result<(), Report> { + let mut pending = vec![(root.to_owned(), String::new())]; + let mut count = 0_usize; + while let Some((directory, prefix)) = pending.pop() { + for entry in fs::read_dir(&directory).map_err(material_error)? { + let entry = entry.map_err(material_error)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| material_error("extracted member is not UTF-8"))?; + let relative = format!("{prefix}{name}"); + let metadata = fs::symlink_metadata(entry.path()).map_err(material_error)?; + if metadata.is_dir() { + pending.push((entry.path(), format!("{relative}/"))); + } else if metadata.is_file() && metadata.len() <= FILE_LIMIT { + count += 1; + if count > FILE_COUNT_LIMIT { + return Err(material_error("extracted crate exceeds the file limit")); + } + let bytes = fs::read(entry.path()).map_err(material_error)?; + writer.write(&relative, &bytes, metadata.mode() & 0o111 != 0)?; + } else { + return Err(material_error( + "extracted crate has an unsafe file type or an oversize file", + )); + } + } + } + Ok(()) +} + +impl LockedMaterials { + /// Requires the supplied inputs to be exactly the locked originals. + fn check_vendor_inputs(&self, inputs: &VendorInputs<'_>) -> Result<(), Report> { + let locked: BTreeSet<_> = self.cargo.registry.keys().collect(); + let supplied: BTreeSet<_> = inputs.registry_archives.keys().collect(); + if locked != supplied { + return Err(material_error( + "registry archives are not exactly the locked registry crates", + )); + } + if inputs.git.len() != self.cargo.git_packages.len() { + return Err(material_error( + "Git inputs are not exactly the locked Git packages", + )); + } + for package in &self.cargo.git_packages { + self.matching_git_input(package, inputs)?; + } + Ok(()) + } + + /// Finds the single input for a locked Git package and checks its bindings. + fn matching_git_input<'a>( + &self, + package: &GitPackage, + inputs: &'a VendorInputs<'a>, + ) -> Result<&'a GitVendorInput<'a>, Report> { + let mut matching = inputs + .git + .iter() + .filter(|input| input.name == package.name && input.version == package.version); + let (Some(input), None) = (matching.next(), matching.next()) else { + return Err(material_error(format!( + "Git package {} {} needs exactly one input", + package.name, package.version + ))); + }; + let material = input.material; + if material.selector() != package.selector + || material.revision() != package.revision + || material.source_commit() != self.source_commit + || material.cargo_lock_sha256() != self.cargo_lock_sha256 + { + return Err(material_error(format!( + "Git closure for {} is not bound to its locked source, revision or approved lock", + package.name + ))); + } + Ok(input) + } + + /// Prepares `output_root/vendor` from verified originals. + /// + /// The root must be an empty real directory. On failure it may hold a + /// partial tree and must be discarded. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] for a non-fresh root, a missing, + /// extra or unverifiable original, a manifest that does not declare the + /// locked identity, an ambiguous or missing Git package root, a Git + /// manifest whose workspace inheritance cannot be normalized, a repeated + /// vendor directory, or an expired or cancelled attempt. + pub(crate) fn prepare_cargo_vendor( + &self, + inputs: &VendorInputs<'_>, + output_root: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + attempt_check(deadline, cancellation)?; + check_output_root(output_root)?; + self.check_vendor_inputs(inputs)?; + let sources: BTreeMap = self + .cargo + .git_packages + .iter() + .map(|package| { + GitSourceConfig::parse(&package.selector) + .map(|source| (source.key().to_owned(), source)) + }) + .collect::>()?; + let vendor = output_root.join(VENDOR_DIRECTORY); + DirBuilder::new() + .mode(0o755) + .create(&vendor) + .map_err(material_error)?; + let mut packages = BTreeMap::new(); + for ((name, version), path) in inputs.registry_archives { + attempt_check(deadline, cancellation)?; + let identity = + self.vendor_registry(name, version, path, &vendor, deadline, cancellation)?; + packages.insert(directory_name(name, version)?, identity); + } + let mut normalized = BTreeMap::new(); + for package in &self.cargo.git_packages { + attempt_check(deadline, cancellation)?; + let input = self.matching_git_input(package, inputs)?; + let identity = vendor_git(package, input, &vendor)?; + if let CargoOrigin::Git { + normalized_manifest_sha256: Some(digest), + .. + } = &identity.origin + { + normalized.insert(git_directory_name(&package.name)?, digest.clone()); + } + packages.insert(git_directory_name(&package.name)?, identity); + } + attempt_check(deadline, cancellation)?; + Ok(PreparedCargoMaterials { + root: output_root.to_owned(), + digest: tool_tree_digest(&vendor, &|| attempt_check(deadline, cancellation))?, + vendor, + source_commit: self.source_commit.clone(), + cargo_lock_sha256: self.cargo_lock_sha256.clone(), + git_sources: sources.into_values().collect(), + normalized_manifests: normalized, + derived_manifests: BTreeMap::new(), + packages, + }) + } + + fn vendor_registry( + &self, + name: &str, + version: &str, + path: &Path, + vendor: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let directory = directory_name(name, version)?; + let checksum = self + .cargo + .registry + .get(&(name.to_owned(), version.to_owned())) + .map(|material| material.sha256.clone()) + .ok_or_else(|| material_error("registry crate is not locked"))?; + let original = self.verify_registry(name, version, path, deadline, cancellation)?; + let extracted = self.extract_registry(name, version, original)?; + extracted.verify_unchanged()?; + let manifest = extracted.read_member_checked("Cargo.toml")?; + require_identity(&manifest, name, version)?; + let mut writer = PackageWriter::create(vendor, &directory)?; + copy_extracted(extracted.root(), &mut writer)?; + extracted.verify_unchanged()?; + let files = writer.finish(Some(&checksum))?; + let prepared_manifest_sha256 = files + .get("Cargo.toml") + .ok_or_else(|| material_error("prepared registry manifest is missing"))? + .clone(); + Ok(VerifiedCargoPackage { + name: name.to_owned(), + version: version.to_owned(), + source: CARGO_REGISTRY.to_owned(), + prepared_manifest_sha256, + origin: CargoOrigin::Registry { + archive_sha256: checksum, + }, + }) + } +} + +/// Vendors one Git package and records its verified original and prepared identity. +fn vendor_git( + package: &GitPackage, + input: &GitVendorInput<'_>, + vendor: &Path, +) -> Result> { + let directory = git_directory_name(&package.name)?; + let store = Store::new(input.material.object_store()); + let root = find_package(&store, input.material.root_tree(), &package.name)?; + let committed = store.read("blob", &root.manifest, MANIFEST_LIMIT)?; + let committed_manifest_sha256 = hex::encode(Sha256::digest(&committed)); + let table = parse_manifest(&committed)?; + check_target_paths(&table)?; + match declared_identity(&table) { + Some((name, _)) if name != package.name => { + return Err(material_error(format!( + "Git manifest declares package {name}, not {}", + package.name + ))); + } + Some((_, Some(version))) if version != package.version => { + return Err(material_error(format!( + "Git manifest declares {} {version}, not {} {}", + package.name, package.name, package.version + ))); + } + Some(_) => {} + None => return Err(material_error("Git manifest declares no package")), + } + let (manifest, original) = if inherits_workspace(&toml::Value::Table(table)) { + let ancestors = ancestor_manifests(&store, input.material.root_tree(), &root)?; + let normalized = normalize_manifest( + &PackageManifest { + name: &package.name, + version: &package.version, + directory: &root.path, + bytes: &committed, + }, + &ancestors, + )?; + (normalized, Some(committed)) + } else { + (committed, None) + }; + let mut writer = PackageWriter::create(vendor, &directory)?; + materialize(&store, input.material.root_tree(), &root, &mut writer)?; + writer.write("Cargo.toml", &manifest, false)?; + if let Some(original) = &original { + writer.write("Cargo.toml.orig", original, false)?; + } + let normalized = original + .as_ref() + .and_then(|_| writer.digest_of("Cargo.toml").map(str::to_owned)); + let files = writer.finish(None)?; + let prepared_manifest_sha256 = files + .get("Cargo.toml") + .ok_or_else(|| material_error("prepared Git manifest is missing"))? + .clone(); + Ok(VerifiedCargoPackage { + name: package.name.clone(), + version: package.version.clone(), + source: format!("{}#{}", package.selector, package.revision), + prepared_manifest_sha256, + origin: CargoOrigin::Git { + revision: package.revision.clone(), + repository_tree: input.material.root_tree().to_owned(), + package_tree: root.tree, + package_path: root.path, + committed_manifest_blob: root.manifest, + committed_manifest_sha256, + normalized_manifest_sha256: normalized, + }, + }) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +impl PreparedCargoMaterials { + /// Adds an identity fixture without bypassing whole-tree rehashing. + pub(crate) fn with_package_for_tests( + mut self, + directory: &str, + package: VerifiedCargoPackage, + ) -> Self { + self.packages.insert(directory.to_owned(), package); + self + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/differential.rs b/crates/trusted-server-release/src/materials/cargo_vendor/differential.rs new file mode 100644 index 000000000..794c72fe3 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/differential.rs @@ -0,0 +1,293 @@ +//! Differential evidence: producer-normalized manifests against `cargo vendor`. +//! +//! The ordinary CI candidate fixture records the manifests `cargo vendor` +//! wrote for each Git package. They are a comparison fixture, never a vendor +//! input. This module reduces two manifests to the facts dependency +//! resolution reads (package identity, features, `links` and every +//! dependency's source, requirement, rename, optionality, default features +//! and feature set) and lists every fact on which they disagree. Equal +//! facts are evidence that the producer resolved inheritance as Cargo does; +//! they confer no authority. +//! +//! Two build-target facts are compared by their Cargo meaning, not their +//! spelling, because `cargo vendor` makes target auto-discovery explicit +//! while the producer leaves it to Cargo: +//! +//! - `package.build`: an absent key and `build = true` both mean "use +//! `build.rs` if the package directory has one", so they compare as +//! `"build.rs"` when that file exists and as `false` when it does not; +//! `build = false` and an explicit path compare as written. +//! - `lib.proc-macro` (or `proc_macro`): absent means `false`. +//! +//! No other absent/explicit pair is treated as equal. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use toml::{Table, Value}; + +use super::parse_manifest; +use crate::error::ReleaseError; +use crate::materials::material_error; + +/// `[package]` fields that bear on identity or resolution. +const PACKAGE_FACTS: [&str; 5] = ["edition", "links", "name", "rust-version", "version"]; +/// Dependency keys compared as written; `features` and defaults are normalized. +const DEPENDENCY_FACTS: [&str; 10] = [ + "branch", "git", "optional", "package", "path", "public", "registry", "rev", "tag", "version", +]; + +/// Lists every resolution fact on which the two manifests disagree. +/// +/// An empty list means they are equivalent for dependency resolution. +/// `build_rs_present` says whether the vendored package directory holds a +/// `build.rs`, which decides what an auto-discovered build script means. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] when either manifest does not parse +/// or holds a malformed `build` or `proc-macro` value. +pub(crate) fn resolution_differences( + producer: &[u8], + cargo_vendor: &[u8], + build_rs_present: bool, +) -> Result, Report> { + let producer = resolution_facts(&parse_manifest(producer)?, build_rs_present)?; + let cargo_vendor = resolution_facts(&parse_manifest(cargo_vendor)?, build_rs_present)?; + let keys: BTreeSet<&String> = producer.keys().chain(cargo_vendor.keys()).collect(); + Ok(keys + .into_iter() + .filter(|key| producer.get(*key) != cargo_vendor.get(*key)) + .map(|key| { + format!( + "{key}: producer={} cargo-vendor={}", + producer.get(key).map_or("", String::as_str), + cargo_vendor.get(key).map_or("", String::as_str), + ) + }) + .collect()) +} + +/// Flattens the resolution-relevant facts of one manifest. +fn resolution_facts( + manifest: &Table, + build_rs_present: bool, +) -> Result, Report> { + let mut facts = BTreeMap::new(); + let package = manifest + .get("package") + .and_then(Value::as_table) + .ok_or_else(|| material_error("compared manifest has no [package] table"))?; + for key in PACKAGE_FACTS { + if let Some(value) = package.get(key) { + facts.insert(format!("package.{key}"), value.to_string()); + } + } + facts.insert( + "package.build".to_owned(), + build_fact(package.get("build"), build_rs_present)?, + ); + let lib = manifest.get("lib").and_then(Value::as_table); + let proc_macro = + match lib.and_then(|lib| lib.get("proc-macro").or_else(|| lib.get("proc_macro"))) { + None => false, + Some(Value::Boolean(value)) => *value, + Some(_) => return Err(material_error("compared lib.proc-macro is not a boolean")), + }; + facts.insert("lib.proc-macro".to_owned(), proc_macro.to_string()); + if let Some(features) = manifest.get("features").and_then(Value::as_table) { + for (name, members) in features { + facts.insert(format!("features.{name}"), sorted_strings(members)?); + } + } + dependency_facts(manifest, "", &mut facts)?; + if let Some(targets) = manifest.get("target").and_then(Value::as_table) { + for (cfg, target) in targets { + if let Some(target) = target.as_table() { + dependency_facts(target, &format!("target.{cfg}."), &mut facts)?; + } + } + } + Ok(facts) +} + +/// The build script Cargo will run, by meaning rather than spelling. +fn build_fact( + build: Option<&Value>, + build_rs_present: bool, +) -> Result> { + match build { + None | Some(Value::Boolean(true)) if build_rs_present => Ok("\"build.rs\"".to_owned()), + None | Some(Value::Boolean(true)) | Some(Value::Boolean(false)) => Ok("false".to_owned()), + Some(Value::String(path)) => Ok(format!("{path:?}")), + Some(_) => Err(material_error("compared package.build is malformed")), + } +} + +/// Records the facts of every dependency table directly inside `scope`. +fn dependency_facts( + scope: &Table, + prefix: &str, + facts: &mut BTreeMap, +) -> Result<(), Report> { + for (section, canonical) in [ + ("dependencies", "dependencies"), + ("dev-dependencies", "dev-dependencies"), + ("dev_dependencies", "dev-dependencies"), + ("build-dependencies", "build-dependencies"), + ("build_dependencies", "build-dependencies"), + ] { + let Some(dependencies) = scope.get(section).and_then(Value::as_table) else { + continue; + }; + for (name, dependency) in dependencies { + let base = format!("{prefix}{canonical}.{name}"); + let detailed = match dependency { + Value::String(version) => { + Table::from_iter([("version".to_owned(), Value::String(version.clone()))]) + } + Value::Table(table) => table.clone(), + _ => return Err(material_error(format!("{base} is malformed"))), + }; + for key in DEPENDENCY_FACTS { + if let Some(value) = detailed.get(key) { + facts.insert(format!("{base}.{key}"), value.to_string()); + } + } + let defaults = detailed + .get("default-features") + .or_else(|| detailed.get("default_features")) + .and_then(Value::as_bool) + .unwrap_or(true); + facts.insert(format!("{base}.default-features"), defaults.to_string()); + if let Some(features) = detailed.get("features") { + facts.insert(format!("{base}.features"), sorted_strings(features)?); + } + if detailed.contains_key("workspace") { + facts.insert(format!("{base}.workspace"), "inherits".to_owned()); + } + } + } + Ok(()) +} + +/// Renders an array of strings as a sorted, de-duplicated list. +fn sorted_strings(value: &Value) -> Result> { + let set: BTreeSet<&str> = value + .as_array() + .and_then(|values| values.iter().map(Value::as_str).collect::>()) + .ok_or_else(|| material_error("compared feature list is not an array of strings"))?; + Ok(set.into_iter().collect::>().join(",")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PRODUCER: &str = "[package]\nname = \"example-core\"\nversion = \"0.4.0\"\nedition = \"2021\"\n\n[dependencies.example-dep]\nversion = \"1\"\nfeatures = [\"alpha\", \"beta\"]\n\n[dependencies.example-sibling]\npath = \"../example-sibling\"\ndefault-features = false\n"; + + #[test] + fn equal_resolution_ignores_order_repeats_and_presentation() { + let cargo_vendor = "[package]\nedition = \"2021\"\nname = \"example-core\"\nversion = \"0.4.0\"\nreadme = \"README.md\"\nautobins = false\n\n[dependencies]\nexample-dep = { version = \"1\", features = [\"beta\", \"alpha\", \"beta\"] }\nexample-sibling = { path = \"../example-sibling\", default_features = false }\n"; + let differences = + resolution_differences(PRODUCER.as_bytes(), cargo_vendor.as_bytes(), false) + .expect("should compare both manifests"); + assert!( + differences.is_empty(), + "should treat equal resolution facts as equivalent: {differences:?}" + ); + } + + #[test] + fn every_resolution_difference_is_reported() { + let cargo_vendor = "[package]\nname = \"example-core\"\nversion = \"0.4.1\"\nedition = \"2021\"\n\n[dependencies.example-dep]\nversion = \"1\"\nfeatures = [\"alpha\"]\n\n[dependencies.example-sibling]\npath = \"../example-sibling\"\n\n[dev-dependencies]\nexample-extra = \"2\"\n"; + let differences = + resolution_differences(PRODUCER.as_bytes(), cargo_vendor.as_bytes(), false) + .expect("should compare both manifests"); + let keys: Vec<&str> = differences + .iter() + .map(|line| line.split(':').next().unwrap_or_default()) + .collect(); + assert_eq!( + keys, + [ + "dependencies.example-dep.features", + "dependencies.example-sibling.default-features", + "dev-dependencies.example-extra.default-features", + "dev-dependencies.example-extra.version", + "package.version", + ], + "should report the version, feature, default-features and missing dependency" + ); + } + + #[test] + fn build_targets_compare_by_cargo_meaning() { + let package = "[package]\nname = \"example-core\"\nversion = \"0.4.0\"\n"; + let with = |extra: &str| format!("{package}{extra}"); + let differs = |producer: &str, cargo_vendor: &str, build_rs_present: bool| { + !resolution_differences( + producer.as_bytes(), + cargo_vendor.as_bytes(), + build_rs_present, + ) + .expect("should compare both manifests") + .is_empty() + }; + let explicit_script = with("build = \"build.rs\"\n"); + let no_script = with("build = false\n"); + let proc_macro = with("\n[lib]\nproc-macro = true\n"); + assert!( + !differs(package, &explicit_script, true), + "should equate auto-discovery with the explicit build.rs that exists" + ); + assert!( + differs(package, &explicit_script, false), + "should not equate auto-discovery with a build.rs that is absent" + ); + assert!( + !differs(package, &no_script, false), + "should equate auto-discovery with build = false when no build.rs exists" + ); + assert!( + differs(package, &no_script, true), + "should not equate auto-discovery with build = false when build.rs exists" + ); + assert!( + !differs(&with("build = true\n"), package, true), + "should equate build = true with an absent key" + ); + assert!( + differs( + &with("build = \"tools/build.rs\"\n"), + &explicit_script, + true + ), + "should compare explicit build paths as written" + ); + assert!( + !differs(package, &with("\n[lib]\nproc-macro = false\n"), false), + "should equate an absent proc-macro with false" + ); + assert!( + differs(package, &proc_macro, false), + "should report a proc-macro difference" + ); + assert!( + !differs(&with("\n[lib]\nproc_macro = true\n"), &proc_macro, false), + "should read the underscore spelling too" + ); + } + + #[test] + fn unparsable_manifests_are_errors() { + assert!( + resolution_differences(b"[package", PRODUCER.as_bytes(), false).is_err(), + "should refuse a manifest that does not parse" + ); + assert!( + resolution_differences(b"[lib]\n", PRODUCER.as_bytes(), false).is_err(), + "should refuse a manifest without a package" + ); + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs b/crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs new file mode 100644 index 000000000..46e20118c --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs @@ -0,0 +1,458 @@ +//! Reads a verified loose-object Git closure and materializes one package. +//! +//! [`VerifiedGitMaterial`](crate::materials::git::VerifiedGitMaterial) owns a +//! private store of zlib-compressed loose objects whose SHA-1 identities were +//! already checked. Every object is rehashed again here, so a store edited +//! after verification is rejected instead of vendored. + +use std::cell::Cell; +use std::collections::BTreeSet; +use std::fs::OpenOptions; +use std::io::Read as _; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::Path; + +use error_stack::Report; +use flate2::read::ZlibDecoder; + +use super::normalize::AncestorManifest; +use super::writer::PackageWriter; +use crate::error::ReleaseError; +use crate::git_objects::checked_sha1; +use crate::materials::material_error; + +/// Largest single object read from the store. +const OBJECT_LIMIT: u64 = 32 * 1024 * 1024; +/// Total object bytes read per vendoring. +const TOTAL_LIMIT: u64 = 512 * 1024 * 1024; +/// Deepest accepted tree nesting. +const DEPTH_LIMIT: usize = 256; +/// Most tree entries visited per vendoring. +const ENTRY_LIMIT: usize = 100_000; +/// Largest manifest examined while looking for a package root. +const MANIFEST_LIMIT: u64 = 1024 * 1024; + +/// One parsed tree entry. +#[derive(Clone, Debug)] +pub(super) struct TreeEntry { + pub(super) name: String, + pub(super) directory: bool, + pub(super) executable: bool, + symlink: bool, + pub(super) oid: String, +} + +/// A rehashing reader over one verified loose-object store. +pub(super) struct Store<'a> { + root: &'a Path, + remaining: Cell, + entries: Cell, +} + +fn lower_oid(text: &str) -> bool { + text.len() == 40 + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Parses inert tree entries, rejecting submodules and unsafe names. +fn parse_tree(mut bytes: &[u8]) -> Result, Report> { + let mut seen = BTreeSet::new(); + let mut entries = Vec::new(); + while !bytes.is_empty() { + let space = bytes + .iter() + .position(|byte| *byte == b' ') + .ok_or_else(|| material_error("malformed Git tree mode"))?; + let (directory, executable, symlink) = match &bytes[..space] { + b"40000" => (true, false, false), + b"100644" => (false, false, false), + b"100755" => (false, true, false), + b"120000" => (false, false, true), + _ => { + return Err(material_error( + "Git package submodules and noncanonical modes are unreviewed", + )); + } + }; + let rest = &bytes[space + 1..]; + let nul = rest + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| material_error("malformed Git tree name"))?; + let name = &rest[..nul]; + let unsafe_name = name.is_empty() + || !name.is_ascii() + || name + .iter() + .any(|byte| byte.is_ascii_control() || matches!(byte, b'/' | b'\\' | b':')) + || matches!(name, b"." | b"..") + || name.eq_ignore_ascii_case(b".git") + || name.len() > 255; + if unsafe_name { + return Err(material_error("unsafe Git package tree name")); + } + if symlink && name == b"Cargo.toml" { + return Err(material_error( + "Git package manifest aliases are unreviewed", + )); + } + if !seen.insert(name.to_ascii_lowercase()) { + return Err(material_error("Git package tree aliases or repeats a name")); + } + let oid = rest + .get(nul + 1..nul + 21) + .ok_or_else(|| material_error("truncated Git tree identity"))?; + entries.push(TreeEntry { + name: String::from_utf8_lossy(name).into_owned(), + directory, + executable, + symlink, + oid: hex::encode(oid), + }); + bytes = &rest[nul + 21..]; + } + Ok(entries) +} + +impl<'a> Store<'a> { + /// Opens a store rooted at the verified material's object directory. + pub(super) fn new(root: &'a Path) -> Self { + Self { + root, + remaining: Cell::new(TOTAL_LIMIT), + entries: Cell::new(0), + } + } + + /// Reads and rehashes one object of `kind`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] for a missing, linked, oversize, + /// malformed or mis-identified object, or an exhausted total budget. + pub(super) fn read( + &self, + kind: &str, + oid: &str, + limit: u64, + ) -> Result, Report> { + if !lower_oid(oid) { + return Err(material_error("Git object id is not canonical")); + } + let path = self.root.join(&oid[..2]).join(&oid[2..]); + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(&path) + .map_err(|error| { + material_error(format!("Git object {oid} cannot be opened: {error}")) + })?; + let metadata = file.metadata().map_err(material_error)?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(material_error("Git object is not one regular file")); + } + let allowed = limit.min(OBJECT_LIMIT); + let mut inflated = Vec::new(); + ZlibDecoder::new(file) + .take(allowed + 64) + .read_to_end(&mut inflated) + .map_err(|error| material_error(format!("Git object {oid} is not zlib: {error}")))?; + let nul = inflated + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| material_error("Git object has no header terminator"))?; + let header = std::str::from_utf8(&inflated[..nul]).map_err(material_error)?; + let (found_kind, length) = header + .split_once(' ') + .ok_or_else(|| material_error("Git object header is malformed"))?; + let length: u64 = length + .parse() + .ok() + .filter(|length: &u64| length.to_string() == header[found_kind.len() + 1..]) + .ok_or_else(|| material_error("Git object length is not canonical"))?; + let body = &inflated[nul + 1..]; + if found_kind != kind || length != body.len() as u64 || length > allowed { + return Err(material_error( + "Git object kind or length differs from its reference or limit", + )); + } + let remaining = self + .remaining + .get() + .checked_sub(length) + .ok_or_else(|| material_error("Git package reads exceed the total byte budget"))?; + self.remaining.set(remaining); + if checked_sha1(&[&inflated[..=nul], body])? != oid { + return Err(material_error( + "Git object bytes do not match their identity", + )); + } + Ok(inflated.split_off(nul + 1)) + } + + fn tree(&self, oid: &str) -> Result, Report> { + let entries = parse_tree(&self.read("tree", oid, OBJECT_LIMIT)?)?; + let visited = self.entries.get() + entries.len(); + if visited > ENTRY_LIMIT { + return Err(material_error("Git package tree exceeds the entry limit")); + } + self.entries.set(visited); + Ok(entries) + } + + /// Resolves only direct relative file links through authenticated tree objects. + fn file_link( + &self, + repository_tree: &str, + path: &str, + target: &[u8], + ) -> Result<(Vec, bool), Report> { + if target.is_empty() + || target.len() > 4096 + || !target.is_ascii() + || target.starts_with(b"/") + || target + .iter() + .any(|byte| byte.is_ascii_control() || matches!(byte, b'\\' | b':')) + { + return Err(material_error("unsafe Git file link target")); + } + let parent = path.rsplit_once('/').map_or("", |(parent, _)| parent); + let mut parts: Vec<&str> = parent.split('/').filter(|part| !part.is_empty()).collect(); + let target = std::str::from_utf8(target).map_err(material_error)?; + if matches!(target.rsplit('/').next(), Some("" | "." | "..")) { + return Err(material_error("Git file link must name a file")); + } + let mut descended = false; + for part in target.split('/') { + match part { + "" => return Err(material_error("empty Git file link target component")), + "." => {} + ".." => { + if descended { + return Err(material_error( + "Git file link traverses a parent after descending", + )); + } + if parts.pop().is_none() { + return Err(material_error("Git file link escapes its repository")); + } + } + _ => { + descended = true; + parts.push(part); + } + } + } + if parts.is_empty() || parts.len() > DEPTH_LIMIT || parts.join("/").len() > 4096 { + return Err(material_error("Git file link target exceeds path limits")); + } + let mut tree = repository_tree.to_owned(); + for (index, part) in parts.iter().enumerate() { + let entry = self + .tree(&tree)? + .into_iter() + .find(|entry| entry.name == *part) + .ok_or_else(|| material_error("Git file link target is missing"))?; + if index + 1 == parts.len() { + if entry.directory || entry.symlink { + return Err(material_error( + "Git file link must target a direct regular file", + )); + } + return Ok(( + self.read("blob", &entry.oid, OBJECT_LIMIT)?, + entry.executable, + )); + } + if !entry.directory { + return Err(material_error( + "Git file link parent is not a direct directory", + )); + } + tree = entry.oid; + } + Err(material_error("Git file link target is missing")) + } +} + +/// The unique directory of the requested package inside a repository tree. +#[derive(Clone, Debug)] +pub(super) struct PackageRoot { + pub(super) tree: String, + pub(super) manifest: String, + pub(super) path: String, +} + +/// Returns the package name a manifest declares, if it parses as a manifest. +fn declared_name(manifest: &[u8]) -> Option { + let text = std::str::from_utf8(manifest).ok()?; + let table: toml::Table = toml::from_str(text).ok()?; + table + .get("package")? + .get("name")? + .as_str() + .map(str::to_owned) +} + +/// Finds the only directory whose `Cargo.toml` declares package `name`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] when no directory or more than one +/// directory declares the package, or the tree exceeds a limit. Manifests +/// that do not parse are not candidates; Cargo could not use them either. +pub(super) fn find_package( + store: &Store<'_>, + root_tree: &str, + name: &str, +) -> Result> { + let mut candidates = Vec::new(); + let mut pending = vec![(root_tree.to_owned(), String::new(), 1_usize)]; + while let Some((tree, path, depth)) = pending.pop() { + if depth > DEPTH_LIMIT { + return Err(material_error("Git package tree exceeds the depth limit")); + } + for entry in store.tree(&tree)? { + let child = if path.is_empty() { + entry.name.clone() + } else { + format!("{path}/{}", entry.name) + }; + if entry.directory { + pending.push((entry.oid, child, depth + 1)); + } else if entry.name == "Cargo.toml" { + let manifest = store.read("blob", &entry.oid, MANIFEST_LIMIT)?; + if declared_name(&manifest).as_deref() == Some(name) { + candidates.push(PackageRoot { + tree: tree.clone(), + manifest: entry.oid, + path: path.clone(), + }); + } + } + } + } + match candidates.as_slice() { + [only] => Ok(only.clone()), + [] => Err(material_error(format!( + "no Git directory declares package {name}" + ))), + _ => Err(material_error(format!( + "ambiguous Git package roots: several directories declare package {name}" + ))), + } +} + +/// Reads every `Cargo.toml` in the directories above `root`, nearest first. +/// +/// The walk descends from the repository root along the package path within +/// the same verified tree, so a workspace root can only come from that tree. +/// The package directory's own manifest is not included. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] when a directory on the package path +/// is missing or not a tree, or an object cannot be read. +pub(super) fn ancestor_manifests( + store: &Store<'_>, + root_tree: &str, + root: &PackageRoot, +) -> Result, Report> { + let mut found = Vec::new(); + let mut tree = root_tree.to_owned(); + let mut directory = String::new(); + let parts: Vec<&str> = root + .path + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + if parts.len() >= DEPTH_LIMIT { + return Err(material_error("Git package tree exceeds the depth limit")); + } + for part in parts { + let entries = store.tree(&tree)?; + if let Some(manifest) = entries + .iter() + .find(|entry| !entry.directory && entry.name == "Cargo.toml") + { + found.push(AncestorManifest { + directory: directory.clone(), + bytes: store.read("blob", &manifest.oid, MANIFEST_LIMIT)?, + }); + } + tree = entries + .into_iter() + .find(|entry| entry.directory && entry.name == part) + .map(|entry| entry.oid) + .ok_or_else(|| { + material_error("Git package path is not a tree in the verified closure") + })?; + directory = if directory.is_empty() { + part.to_owned() + } else { + format!("{directory}/{part}") + }; + } + if tree != root.tree { + return Err(material_error( + "Git package path does not lead to the package tree", + )); + } + found.reverse(); + Ok(found) +} + +/// Writes the package tree below `root.tree` through `writer`. +/// +/// Subdirectories with their own `Cargo.toml` are separate packages and are +/// skipped, as Cargo's own file listing does. The root `Cargo.toml` is not +/// written here: the caller decides which manifest bytes represent it. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] for an unreadable object or write. +pub(super) fn materialize( + store: &Store<'_>, + repository_tree: &str, + root: &PackageRoot, + writer: &mut PackageWriter, +) -> Result<(), Report> { + let mut pending = vec![(store.tree(&root.tree)?, String::new(), 1_usize)]; + while let Some((entries, prefix, depth)) = pending.pop() { + if depth > DEPTH_LIMIT { + return Err(material_error("Git package tree exceeds the depth limit")); + } + for entry in &entries { + let relative = format!("{prefix}{}", entry.name); + if entry.directory { + let child = store.tree(&entry.oid)?; + let nested_package = child + .iter() + .any(|inner| !inner.directory && inner.name == "Cargo.toml"); + if !nested_package { + pending.push((child, format!("{relative}/"), depth + 1)); + } + } else if relative != "Cargo.toml" { + let (bytes, executable) = if entry.symlink { + let target = store.read("blob", &entry.oid, 4096)?; + let path = if root.path.is_empty() { + relative.clone() + } else { + format!("{}/{relative}", root.path) + }; + store.file_link(repository_tree, &path, &target)? + } else { + ( + store.read("blob", &entry.oid, OBJECT_LIMIT)?, + entry.executable, + ) + }; + writer.write(&relative, &bytes, executable)?; + } + } + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/index.rs b/crates/trusted-server-release/src/materials/cargo_vendor/index.rs new file mode 100644 index 000000000..f75e600ac --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/index.rs @@ -0,0 +1,108 @@ +//! Package identities recorded only after verified originals finish vendoring. + +use serde::Serialize; + +/// Original material behind one prepared Cargo directory-source package. +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum CargoOrigin { + /// A distribution verified against the approved lock checksum. + Registry { + /// SHA-256 of the original crate archive. + archive_sha256: String, + }, + /// A package extracted from a verified Git object closure. + Git { + /// Exact locked commit object identity. + revision: String, + /// Root tree of the verified repository commit. + repository_tree: String, + /// Tree containing the selected package. + package_tree: String, + /// Repository-relative package directory; empty for a root package. + package_path: String, + /// Committed manifest blob object identity. + committed_manifest_blob: String, + /// SHA-256 of the committed manifest bytes before normalization. + committed_manifest_sha256: String, + /// SHA-256 of a producer-normalized manifest, when normalization occurred. + normalized_manifest_sha256: Option, + }, +} + +/// Identity of a package written by verified Cargo material preparation. +/// +/// Membership is authoritative only through its owning +/// [`PreparedCargoMaterials`](super::PreparedCargoMaterials), whose whole tree +/// must be rehashed before any observed compiler identity is joined to it. +#[derive(Clone, Debug, Serialize)] +pub(crate) struct VerifiedCargoPackage { + /// Name checked against the approved lock identity. + pub(super) name: String, + /// Version checked against the approved lock identity. + pub(super) version: String, + /// Complete locked registry or Git source, including any revision. + pub(super) source: String, + /// SHA-256 of the manifest written into the directory source. + pub(super) prepared_manifest_sha256: String, + /// Verified original identity before any manifest normalization. + pub(super) origin: CargoOrigin, +} + +impl VerifiedCargoPackage { + /// The package name checked against the locked identity. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// The package version checked against the locked identity. + pub(crate) fn version(&self) -> &str { + &self.version + } + + /// Complete original Cargo source identity, including a Git revision. + pub(crate) fn source(&self) -> &str { + &self.source + } + + /// SHA-256 of the prepared manifest Cargo reads from the vendor tree. + pub(crate) fn prepared_manifest_sha256(&self) -> &str { + &self.prepared_manifest_sha256 + } + + /// Original archive or committed Git package material identity. + pub(crate) fn origin(&self) -> &CargoOrigin { + &self.origin + } +} + +#[cfg(test)] +impl VerifiedCargoPackage { + /// Creates an origin fixture whose prepared bytes are measured separately. + pub(crate) fn registry_for_tests( + name: &str, + version: &str, + source: &str, + manifest_sha256: &str, + archive_sha256: &str, + ) -> Self { + Self { + name: name.to_owned(), + version: version.to_owned(), + source: source.to_owned(), + prepared_manifest_sha256: manifest_sha256.to_owned(), + origin: CargoOrigin::Registry { + archive_sha256: archive_sha256.to_owned(), + }, + } + } +} + +#[cfg(test)] +impl VerifiedCargoPackage { + /// Replaces a fictional original identity for exact-source join tests. + pub(crate) fn with_origin_for_tests(mut self, origin: CargoOrigin) -> Self { + self.origin = origin; + self + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs b/crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs new file mode 100644 index 000000000..9c8e7b98d --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs @@ -0,0 +1,630 @@ +//! Producer-owned normalization of Git package manifests. +//! +//! A Git package whose `Cargo.toml` inherits from its workspace +//! (`version.workspace = true`, `serde = { workspace = true }`, `[lints] +//! workspace = true`) cannot be read from a directory source as committed. +//! This module resolves that inheritance itself, from the package manifest +//! and the workspace root manifest of the same verified Git tree, so no +//! caller-supplied manifest is ever trusted. It is pure: it reads only the +//! bytes it is handed. +//! +//! The rules follow the Cargo reference for workspace inheritance: +//! +//! - The workspace root is the package manifest itself when it has a +//! `[workspace]` table, else the nearest enclosing directory whose +//! `Cargo.toml` has one. A root whose `exclude` covers the package is +//! refused rather than skipped. +//! - `[package]` fields listed in [`INHERITED_PACKAGE_FIELDS`] are copied from +//! `[workspace.package]`. +//! - A dependency with `workspace = true` starts from the +//! `[workspace.dependencies]` entry of the same key. Its `features` are +//! added to the workspace features, its `optional` is taken as written, and +//! `default-features = true` re-enables defaults the workspace turned off. +//! A workspace `path` is rewritten relative to the package directory, as +//! Cargo does; in the vendor layout every Git package is `vendor//`, +//! so sibling paths keep resolving. +//! - `[lints] workspace = true` is replaced by `[workspace.lints]`. +//! +//! Anything else that could change resolution is refused: unknown or +//! path-relocating inherited fields (`readme`, `license-file`), `[patch]` and +//! `[replace]`, a `package.workspace` pointer, unsupported keys on either side +//! of an inherited dependency, and a member `default-features = false` that +//! Cargo would ignore or reject. The output is serialized with every table's +//! keys sorted, so equal inputs always yield equal bytes. + +use error_stack::Report; +use toml::{Table, Value}; + +use super::{inherits_workspace, parse_manifest, require_identity}; +use crate::error::ReleaseError; +use crate::materials::material_error; + +/// `[package]` fields resolved from `[workspace.package]`. +pub(super) const INHERITED_PACKAGE_FIELDS: [&str; 14] = [ + "authors", + "categories", + "description", + "documentation", + "edition", + "exclude", + "homepage", + "include", + "keywords", + "license", + "publish", + "repository", + "rust-version", + "version", +]; +/// Inheritable fields that hold a workspace-relative path; never relocated. +const RELOCATING_PACKAGE_FIELDS: [&str; 2] = ["license-file", "readme"]; +/// Dependency tables, at the top level and under `[target.]`. +const DEPENDENCY_TABLES: [&str; 3] = ["build-dependencies", "dependencies", "dev-dependencies"]; +/// Legacy spellings that are refused instead of merged. +const LEGACY_DEPENDENCY_TABLES: [&str; 2] = ["build_dependencies", "dev_dependencies"]; +/// Keys a member may write beside `workspace = true` on a dependency. +const MEMBER_DEPENDENCY_KEYS: [&str; 4] = ["default-features", "features", "optional", "workspace"]; +/// Keys a `[workspace.dependencies]` entry may hold. +const WORKSPACE_DEPENDENCY_KEYS: [&str; 5] = + ["default-features", "features", "package", "path", "version"]; +/// First lines of every producer-normalized manifest. +const HEADER: &str = "# Normalized by trusted-server-release from the verified Git tree:\n\ +# workspace inheritance is resolved against the enclosing workspace root.\n\ +# The committed manifest is kept beside this file as Cargo.toml.orig.\n\n"; + +/// The committed manifest of one locked Git package. +pub(super) struct PackageManifest<'a> { + /// Locked package name. + pub(super) name: &'a str, + /// Locked package version. + pub(super) version: &'a str, + /// Repository-relative `/`-separated directory; empty at the root. + pub(super) directory: &'a str, + /// Committed manifest bytes. + pub(super) bytes: &'a [u8], +} + +/// A `Cargo.toml` found in a directory above the package, in the same tree. +#[derive(Debug)] +pub(super) struct AncestorManifest { + /// Repository-relative `/`-separated directory; empty at the root. + pub(super) directory: String, + /// Committed manifest bytes. + pub(super) bytes: Vec, +} + +/// The workspace a package inherits from. +struct WorkspaceRoot { + directory: String, + table: Table, +} + +/// Resolves the package's workspace inheritance and returns the new manifest. +/// +/// `ancestors` must be ordered nearest first and come from the same verified +/// tree as the package. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] for a manifest that does not parse, no +/// enclosing workspace root, an inherited value the root does not define, an +/// unsupported or path-relocating inherited key, `[patch]`, `[replace]` or a +/// `package.workspace` pointer, a result that still inherits, or a result +/// whose name or version differs from the locked identity. +pub(super) fn normalize_manifest( + package: &PackageManifest<'_>, + ancestors: &[AncestorManifest], +) -> Result, Report> { + let mut manifest = parse_manifest(package.bytes)?; + for refused in ["patch", "replace"] { + if manifest.contains_key(refused) { + return Err(material_error(format!( + "Git package {} declares [{refused}], which normalization does not support", + package.name + ))); + } + } + let root = match manifest.remove("workspace") { + Some(Value::Table(table)) => WorkspaceRoot { + directory: package.directory.to_owned(), + table, + }, + Some(_) => return Err(material_error("[workspace] is not a table")), + None => enclosing_root(package, ancestors)?, + }; + resolve_package_fields(&mut manifest, &root, package.name)?; + resolve_dependency_tables(&mut manifest, &root, package.directory)?; + resolve_lints(&mut manifest, &root)?; + if inherits_workspace(&Value::Table(manifest.clone())) { + return Err(material_error(format!( + "Git package {} still inherits from its workspace after normalization", + package.name + ))); + } + let manifest = sorted_table(manifest); + let body = toml::to_string(&manifest).map_err(material_error)?; + let bytes = format!("{HEADER}{body}").into_bytes(); + if parse_manifest(&bytes)? != manifest { + return Err(material_error( + "normalized manifest does not round-trip through TOML", + )); + } + require_identity(&bytes, package.name, package.version)?; + Ok(bytes) +} + +/// Finds the nearest enclosing `[workspace]` root that does not exclude the package. +fn enclosing_root( + package: &PackageManifest<'_>, + ancestors: &[AncestorManifest], +) -> Result> { + for ancestor in ancestors { + let mut table = parse_manifest(&ancestor.bytes)?; + let package_table = table.get("package").and_then(Value::as_table); + if package_table.is_some_and(|package| package.contains_key("workspace")) { + return Err(material_error(format!( + "ancestor manifest in {:?} points to another workspace root; unsupported", + ancestor.directory + ))); + } + let has_package = package_table.is_some(); + let workspace = match table.remove("workspace") { + Some(Value::Table(workspace)) => workspace, + Some(_) => return Err(material_error("[workspace] is not a table")), + None if has_package => continue, + None => { + return Err(material_error(format!( + "ancestor manifest in {:?} has neither [package] nor [workspace]", + ancestor.directory + ))); + } + }; + let relative = within(&ancestor.directory, package.directory).ok_or_else(|| { + material_error("workspace candidate does not enclose the Git package") + })?; + if excluded(&workspace, &relative)? { + return Err(material_error(format!( + "Git package {} is excluded from its nearest workspace root; unsupported", + package.name + ))); + } + return Ok(WorkspaceRoot { + directory: ancestor.directory.clone(), + table: workspace, + }); + } + Err(material_error(format!( + "Git package {} inherits from a workspace, but no workspace root encloses it in the verified tree", + package.name + ))) +} + +/// Splits a repository-relative directory into its components. +fn components(directory: &str) -> Vec<&str> { + directory + .split('/') + .filter(|part| !part.is_empty()) + .collect() +} + +/// Returns `directory` relative to `ancestor`, if `ancestor` encloses it. +fn within(ancestor: &str, directory: &str) -> Option> { + let ancestor = components(ancestor); + let directory = components(directory); + directory + .strip_prefix(ancestor.as_slice()) + .map(|rest| rest.iter().map(|part| (*part).to_owned()).collect()) +} + +/// Reports whether the root's `exclude` list covers `relative`. +fn excluded(workspace: &Table, relative: &[String]) -> Result> { + let Some(exclude) = workspace.get("exclude") else { + return Ok(false); + }; + for entry in string_array(exclude, "workspace.exclude")? { + let parts: Vec<&str> = components(&entry) + .into_iter() + .filter(|part| *part != ".") + .collect(); + if parts.contains(&"..") { + return Err(material_error( + "workspace.exclude leaves the workspace root; unsupported", + )); + } + if relative.len() >= parts.len() + && parts + .iter() + .zip(relative) + .all(|(left, right)| left == right) + { + return Ok(true); + } + } + Ok(false) +} + +/// Returns `value` as an array of strings. +fn string_array(value: &Value, label: &str) -> Result, Report> { + value + .as_array() + .and_then(|values| { + values + .iter() + .map(|value| value.as_str().map(str::to_owned)) + .collect::>>() + }) + .ok_or_else(|| material_error(format!("{label} is not an array of strings"))) +} + +/// Reports whether `value` is a `{ workspace = ... }` inheritance marker, +/// requiring exactly `{ workspace = true }` when it is. +fn is_marker(value: &Value, label: &str) -> Result> { + let Some(table) = value.as_table() else { + return Ok(false); + }; + match table.get("workspace") { + None => Ok(false), + Some(Value::Boolean(true)) if table.len() == 1 => Ok(true), + Some(_) => Err(material_error(format!( + "{label} must be exactly {{ workspace = true }} to inherit" + ))), + } +} + +/// Replaces inherited `[package]` fields with `[workspace.package]` values. +fn resolve_package_fields( + manifest: &mut Table, + root: &WorkspaceRoot, + name: &str, +) -> Result<(), Report> { + let package = manifest + .get_mut("package") + .and_then(Value::as_table_mut) + .ok_or_else(|| material_error("Git manifest has no [package] table"))?; + if package.get("name").and_then(Value::as_str) != Some(name) { + return Err(material_error(format!( + "Git manifest does not name package {name} as a plain string" + ))); + } + if package.contains_key("workspace") { + return Err(material_error( + "package.workspace pointers are not supported by normalization", + )); + } + let defaults = root.table.get("package").and_then(Value::as_table); + for (key, value) in package.iter_mut() { + if !is_marker(value, &format!("package.{key}"))? { + continue; + } + if RELOCATING_PACKAGE_FIELDS.contains(&key.as_str()) { + return Err(material_error(format!( + "inherited package.{key} names a workspace-relative path; unsupported" + ))); + } + if !INHERITED_PACKAGE_FIELDS.contains(&key.as_str()) { + return Err(material_error(format!( + "package.{key} is not an inheritable field" + ))); + } + *value = defaults + .and_then(|defaults| defaults.get(key)) + .cloned() + .ok_or_else(|| { + material_error(format!( + "package.{key} inherits a value [workspace.package] does not define" + )) + })?; + } + Ok(()) +} + +/// Resolves inherited dependencies in every top-level and target table. +fn resolve_dependency_tables( + manifest: &mut Table, + root: &WorkspaceRoot, + package_directory: &str, +) -> Result<(), Report> { + let workspace_dependencies = root.table.get("dependencies").and_then(Value::as_table); + let resolver = DependencyResolver { + workspace_dependencies, + workspace_directory: &root.directory, + package_directory, + }; + resolver.resolve_tables(manifest, "")?; + if let Some(targets) = manifest.get_mut("target") { + let targets = targets + .as_table_mut() + .ok_or_else(|| material_error("[target] is not a table"))?; + for (cfg, target) in targets.iter_mut() { + let target = target + .as_table_mut() + .ok_or_else(|| material_error(format!("target.{cfg} is not a table")))?; + resolver.resolve_tables(target, &format!("target.{cfg}."))?; + } + } + Ok(()) +} + +/// The workspace context one package's inherited dependencies resolve in. +struct DependencyResolver<'a> { + workspace_dependencies: Option<&'a Table>, + workspace_directory: &'a str, + package_directory: &'a str, +} + +impl DependencyResolver<'_> { + /// Resolves the dependency tables directly inside `scope`. + fn resolve_tables(&self, scope: &mut Table, prefix: &str) -> Result<(), Report> { + for legacy in LEGACY_DEPENDENCY_TABLES { + if scope.contains_key(legacy) { + return Err(material_error(format!( + "{prefix}{legacy} is a legacy spelling normalization does not support" + ))); + } + } + for section in DEPENDENCY_TABLES { + let Some(dependencies) = scope.get_mut(section) else { + continue; + }; + let dependencies = dependencies + .as_table_mut() + .ok_or_else(|| material_error(format!("{prefix}{section} is not a table")))?; + for (key, dependency) in dependencies.iter_mut() { + let label = format!("{prefix}{section}.{key}"); + if dependency + .as_table() + .is_some_and(|table| table.contains_key("workspace")) + { + *dependency = Value::Table(self.inherit(key, dependency, &label)?); + } + } + } + Ok(()) + } + + /// Builds one inherited dependency from its workspace entry. + fn inherit( + &self, + key: &str, + member: &Value, + label: &str, + ) -> Result> { + let member = member + .as_table() + .ok_or_else(|| material_error(format!("{label} is not a table")))?; + if member.get("workspace") != Some(&Value::Boolean(true)) { + return Err(material_error(format!("{label} must set workspace = true"))); + } + if let Some(unsupported) = member + .keys() + .find(|name| !MEMBER_DEPENDENCY_KEYS.contains(&name.as_str())) + { + return Err(material_error(format!( + "{label} sets {unsupported} beside workspace = true; unsupported" + ))); + } + let mut resolved = match self.workspace_dependencies.and_then(|table| table.get(key)) { + Some(Value::String(version)) => { + Table::from_iter([("version".to_owned(), Value::String(version.clone()))]) + } + Some(Value::Table(table)) => table.clone(), + Some(_) => { + return Err(material_error(format!( + "workspace.dependencies.{key} is neither a version nor a table" + ))); + } + None => { + return Err(material_error(format!( + "{label} inherits workspace.dependencies.{key}, which is not defined" + ))); + } + }; + if let Some(unsupported) = resolved + .keys() + .find(|name| !WORKSPACE_DEPENDENCY_KEYS.contains(&name.as_str())) + { + return Err(material_error(format!( + "workspace.dependencies.{key} sets {unsupported}; unsupported" + ))); + } + if let Some(path) = resolved.get("path") { + let path = path.as_str().ok_or_else(|| { + material_error(format!("workspace.dependencies.{key}.path is not a string")) + })?; + let relocated = relocate(self.workspace_directory, path, self.package_directory)?; + resolved.insert("path".to_owned(), Value::String(relocated)); + } + merge_default_features(&mut resolved, member, label)?; + merge_features(&mut resolved, member, label)?; + if let Some(optional) = member.get("optional") { + if !optional.is_bool() { + return Err(material_error(format!("{label}.optional is not a boolean"))); + } + resolved.insert("optional".to_owned(), optional.clone()); + } + Ok(resolved) + } +} + +/// Applies Cargo's `default-features` rule for an inherited dependency. +fn merge_default_features( + resolved: &mut Table, + member: &Table, + label: &str, +) -> Result<(), Report> { + let boolean = |table: &Table, side: &str| match table.get("default-features") { + None => Ok(None), + Some(Value::Boolean(value)) => Ok(Some(*value)), + Some(_) => Err(material_error(format!( + "{side} default-features of {label} is not a boolean" + ))), + }; + let workspace = boolean(resolved, "workspace")?; + match (boolean(member, "member")?, workspace) { + (Some(true), Some(false)) => { + resolved.insert("default-features".to_owned(), Value::Boolean(true)); + } + (Some(false), workspace) if workspace != Some(false) => { + return Err(material_error(format!( + "{label} sets default-features = false, which Cargo ignores unless the workspace entry does too" + ))); + } + _ => {} + } + Ok(()) +} + +/// Appends member `features` to the workspace features, keeping the first +/// occurrence of each, as Cargo's ordered feature set does. +fn merge_features( + resolved: &mut Table, + member: &Table, + label: &str, +) -> Result<(), Report> { + let workspace = match resolved.get("features") { + Some(value) => string_array(value, &format!("workspace features of {label}"))?, + None => Vec::new(), + }; + let added = match member.get("features") { + Some(value) => string_array(value, &format!("{label}.features"))?, + None => Vec::new(), + }; + let mut features: Vec = Vec::new(); + for feature in workspace.into_iter().chain(added) { + if !features.contains(&feature) { + features.push(feature); + } + } + if !features.is_empty() { + resolved.insert( + "features".to_owned(), + Value::Array(features.into_iter().map(Value::String).collect()), + ); + } + Ok(()) +} + +/// Refuses a build script or target path that could leave the package directory. +/// +/// Applies to every vendored Git manifest, inheriting or not: `package.build` +/// (when a path) and the `path` of `[lib]` and each `[[bin]]`, `[[example]]`, +/// `[[test]]` and `[[bench]]` must be relative and free of `..` components, +/// so a vendored package only builds from files inside its own directory. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] for a non-string, absolute or +/// parent-relative path, or a malformed target table. +pub(super) fn check_target_paths(manifest: &Table) -> Result<(), Report> { + let mut paths: Vec<(String, &Value)> = Vec::new(); + if let Some(build) = manifest + .get("package") + .and_then(|package| package.get("build")) + .filter(|build| !build.is_bool()) + { + paths.push(("package.build".to_owned(), build)); + } + if let Some(path) = manifest.get("lib").and_then(|lib| lib.get("path")) { + paths.push(("lib.path".to_owned(), path)); + } + for kind in ["bin", "example", "test", "bench"] { + let Some(targets) = manifest.get(kind) else { + continue; + }; + let targets = targets + .as_array() + .ok_or_else(|| material_error(format!("[[{kind}]] is not an array of tables")))?; + for (index, target) in targets.iter().enumerate() { + if let Some(path) = target.get("path") { + paths.push((format!("{kind}[{index}].path"), path)); + } + } + } + for (label, value) in paths { + let path = value + .as_str() + .ok_or_else(|| material_error(format!("{label} is not a string")))?; + if path.starts_with('/') || path.split(['/', '\\']).any(|part| part == "..") { + return Err(material_error(format!( + "{label} {path:?} leaves the vendored package directory; unsupported" + ))); + } + } + Ok(()) +} + +/// Rewrites a workspace-relative `path` relative to the package directory. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] for an empty, absolute or +/// backslash path, or one that leaves the repository. +pub(super) fn relocate( + workspace_directory: &str, + path: &str, + package_directory: &str, +) -> Result> { + if path.is_empty() || path.starts_with('/') || path.contains('\\') { + return Err(material_error(format!( + "workspace dependency path {path:?} is not a relative repository path" + ))); + } + let mut target = components(workspace_directory); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => { + target.pop().ok_or_else(|| { + material_error(format!( + "workspace dependency path {path:?} leaves the repository" + )) + })?; + } + other => target.push(other), + } + } + let package = components(package_directory); + let common = target + .iter() + .zip(&package) + .take_while(|(left, right)| left == right) + .count(); + let mut relative: Vec<&str> = vec![".."; package.len() - common]; + relative.extend_from_slice(&target[common..]); + Ok(if relative.is_empty() { + ".".to_owned() + } else { + relative.join("/") + }) +} + +/// Replaces `[lints] workspace = true` with `[workspace.lints]`. +fn resolve_lints(manifest: &mut Table, root: &WorkspaceRoot) -> Result<(), Report> { + let Some(lints) = manifest.get_mut("lints") else { + return Ok(()); + }; + if is_marker(lints, "[lints]")? { + *lints = root.table.get("lints").cloned().ok_or_else(|| { + material_error("[lints] inherits [workspace.lints], which is not defined") + })?; + } + Ok(()) +} + +/// Rebuilds every table with its keys in sorted order. +fn sorted_table(table: Table) -> Table { + let mut entries: Vec<(String, Value)> = table.into_iter().collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + entries + .into_iter() + .map(|(key, value)| (key, sorted_value(value))) + .collect() +} + +fn sorted_value(value: Value) -> Value { + match value { + Value::Table(table) => Value::Table(sorted_table(table)), + Value::Array(values) => Value::Array(values.into_iter().map(sorted_value).collect()), + other => other, + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/source.rs b/crates/trusted-server-release/src/materials/cargo_vendor/source.rs new file mode 100644 index 000000000..b796997c3 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/source.rs @@ -0,0 +1,119 @@ +//! Cargo source identities of locked Git packages, for source replacement. + +use error_stack::Report; + +use crate::error::ReleaseError; +use crate::materials::material_error; + +/// The Git reference a locked source was requested with. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum GitReference { + /// No `?` selector in the lock: the repository's default branch. + DefaultBranch, + /// `?tag=`. + Tag(String), + /// `?branch=`. + Branch(String), + /// `?rev=`. + Rev(String), +} + +/// One locked Git source as Cargo's `[source.""]` table spells it. +/// +/// The key is the exact lock source without its `#` suffix, which is +/// how `cargo vendor` names the table. Cargo also needs the `git` location +/// and the reference in the same table: a bare `replace-with` is rejected +/// with "no source location specified". +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GitSourceConfig { + key: String, + url: String, + reference: GitReference, +} + +/// Characters a reference may carry; anything else would need escaping. +/// +/// This deliberately rejects `/` (and so branch names such as `feature/x`, +/// which Cargo writes percent-encoded) and `%`: a locked source needing them +/// fails closed here until the encoding is reviewed and supported. +fn reference_text(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +/// Requires an `https` URL that can sit unescaped inside a TOML basic string. +fn url_text(url: &str) -> bool { + url.strip_prefix("https://").is_some_and(|rest| { + !rest.is_empty() + && rest.bytes().all(|byte| { + byte.is_ascii_graphic() && !matches!(byte, b'"' | b'\\' | b'#' | b'?' | b'%') + }) + }) +} + +impl GitSourceConfig { + /// Parses a lock source selector such as `git+https://example.com/org/repo?tag=v1`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] for a selector that is not `git+https`, + /// carries more than one reference, an empty or unusual reference value, + /// or characters that would need escaping in a Cargo configuration. + pub(crate) fn parse(selector: &str) -> Result> { + let location = selector + .strip_prefix("git+") + .ok_or_else(|| material_error("Git source selector must start with git+"))?; + let (url, query) = match location.split_once('?') { + Some((url, query)) => (url, Some(query)), + None => (location, None), + }; + if !url_text(url) { + return Err(material_error( + "Git source URL must be plain https text without escapes", + )); + } + let reference = match query { + None => GitReference::DefaultBranch, + Some(query) => match query.split_once('=') { + Some((kind, value)) if reference_text(value) => match kind { + "tag" => GitReference::Tag(value.to_owned()), + "branch" => GitReference::Branch(value.to_owned()), + "rev" => GitReference::Rev(value.to_owned()), + _ => return Err(material_error("Git source reference kind is unsupported")), + }, + _ => { + return Err(material_error( + "Git source must carry exactly one plain tag, branch or rev", + )); + } + }, + }; + Ok(Self { + key: selector.to_owned(), + url: url.to_owned(), + reference, + }) + } + + /// The exact locked selector, used as the Cargo source table name. + pub(crate) fn key(&self) -> &str { + &self.key + } + + /// The repository URL for the table's `git` key. + pub(crate) fn url(&self) -> &str { + &self.url + } + + /// The reference as a Cargo key and value, or `None` for the default branch. + pub(crate) fn reference(&self) -> Option<(&'static str, &str)> { + match &self.reference { + GitReference::DefaultBranch => None, + GitReference::Tag(value) => Some(("tag", value)), + GitReference::Branch(value) => Some(("branch", value)), + GitReference::Rev(value) => Some(("rev", value)), + } + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests.rs new file mode 100644 index 000000000..ab96f95f6 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests.rs @@ -0,0 +1,5 @@ +mod git; +mod normalize; +mod registry; +mod source; +mod support; diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs new file mode 100644 index 000000000..a5aa6bb4b --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs @@ -0,0 +1,801 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt as _; + +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::super::git_tree::{PackageRoot, Store, ancestor_manifests}; +use super::support::*; +use crate::materials::locked::GitPackage; +use crate::materials::{GitVendorInput, LockedMaterials, PreparedCargoMaterials, VendorInputs}; + +const INHERITING: &str = "[package]\nname = \"example-core\"\nversion.workspace = true\nedition.workspace = true\n\n[dependencies]\nexample-dep = { workspace = true, features = [\"extra\"] }\n"; +const PLAIN: &str = "[package]\nname = \"example-core\"\nversion = \"0.4.0\"\nedition = \"2021\"\n"; +const WORKSPACE_ROOT: &str = "[workspace]\nmembers = [\"crates/core\"]\n\n[workspace.package]\nversion = \"0.4.0\"\nedition = \"2021\"\n\n[workspace.dependencies]\nexample-dep = { version = \"1\", features = [\"base\"] }\n"; + +/// A fictional workspace: root manifest plus `crates/core` and a nested package. +struct Workspace { + objects: Objects, + root_tree: String, + manifest_blob: String, +} + +fn workspace(core_manifest: &str, extra_core_files: &[(&str, &[u8])]) -> Workspace { + workspace_with_root(core_manifest, Some(WORKSPACE_ROOT), extra_core_files) +} + +fn workspace_with_root( + core_manifest: &str, + workspace_manifest: Option<&str>, + extra_core_files: &[(&str, &[u8])], +) -> Workspace { + let objects = Objects::new(); + let manifest_blob = objects.blob(core_manifest.as_bytes()); + let lib = objects.blob(b"pub fn core() {}\n"); + let script = objects.blob(b"#!/bin/sh\n"); + let src = objects.tree(&[("100644", "lib.rs", &lib)]); + let nested_manifest = objects.blob(&manifest("example-nested", "0.1.0")); + let nested_lib = objects.blob(b"pub fn nested() {}\n"); + let nested_src = objects.tree(&[("100644", "lib.rs", &nested_lib)]); + let nested = objects.tree(&[ + ("100644", "Cargo.toml", &nested_manifest), + ("40000", "src", &nested_src), + ]); + let extra_blobs: Vec<(&str, String)> = extra_core_files + .iter() + .map(|(name, bytes)| (*name, objects.blob(bytes))) + .collect(); + let mut entries: Vec<(&str, &str, &str)> = vec![ + ("100644", "Cargo.toml", &manifest_blob), + ("100755", "run.sh", &script), + ("40000", "nested", &nested), + ("40000", "src", &src), + ]; + entries.extend( + extra_blobs + .iter() + .map(|(name, oid)| ("100644", *name, oid.as_str())), + ); + entries.sort_by_key(|entry| entry.1); + let core = objects.tree(&entries); + let crates = objects.tree(&[("40000", "core", &core)]); + let root_blob = workspace_manifest.map(|text| objects.blob(text.as_bytes())); + let mut root_entries = vec![("40000", "crates", crates.as_str())]; + if let Some(blob) = &root_blob { + root_entries.insert(0, ("100644", "Cargo.toml", blob.as_str())); + } + let root_tree = objects.tree(&root_entries); + Workspace { + objects, + root_tree, + manifest_blob, + } +} + +struct Run { + _output: tempfile::TempDir, + root: std::path::PathBuf, + outcome: Result, +} + +fn run(locked: &LockedMaterials, material: &crate::materials::git::VerifiedGitMaterial) -> Run { + let output = temporary(); + let root = output.path().to_owned(); + let package = &locked.cargo.git_packages[0]; + let inputs = [GitVendorInput { + material, + name: &package.name, + version: &package.version, + }]; + let outcome = locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: &Default::default(), + git: &inputs, + }, + &root, + deadline(), + &cancellation(), + ) + .map_err(|error| format!("{error:?}")); + Run { + _output: output, + root, + outcome, + } +} + +fn locked_core() -> LockedMaterials { + locked(&[], vec![git_package("example-core", "0.4.0")]) +} + +fn documentation_links(readme_target: &[u8]) -> Workspace { + let objects = Objects::new(); + let manifest_blob = objects.blob(PLAIN.as_bytes()); + let license = objects.blob(b"example license bytes\n"); + let readme = objects.blob(b"example readme bytes\n"); + let license_link = objects.blob(b"../LICENSE"); + let readme_link = objects.blob(readme_target); + let indirect = objects.blob(b"README.md"); + let directory = objects.tree(&[]); + let package = objects.tree(&[ + ("100644", "Cargo.toml", &manifest_blob), + ("120000", "LICENSE", &license_link), + ("120000", "README.md", &readme_link), + ]); + let root_tree = objects.tree(&[ + ("100644", "LICENSE", &license), + ("100755", "README.md", &readme), + ("120000", "alias", &indirect), + ("40000", "directory", &directory), + ("40000", "package", &package), + ]); + Workspace { + objects, + root_tree, + manifest_blob, + } +} + +#[test] +fn git_documentation_links_copy_verified_parent_files_as_regular_files() { + let built = documentation_links(b"../README.md"); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let result = run(&locked_core(), &material); + result + .outcome + .expect("should vendor direct documentation file links"); + let package = result.root.join("vendor/example-core"); + for (name, expected, mode) in [ + ("LICENSE", &b"example license bytes\n"[..], 0o644), + ("README.md", &b"example readme bytes\n"[..], 0o755), + ] { + let path = package.join(name); + assert!( + fs::symlink_metadata(&path) + .expect("should inspect copied file") + .is_file(), + "should write a regular file" + ); + assert_eq!( + fs::metadata(&path) + .expect("should inspect copied permissions") + .permissions() + .mode() + & 0o7777, + mode, + "should preserve the target executable mode" + ); + assert_eq!( + fs::read(path).expect("should read copied file"), + expected, + "should copy the authenticated target bytes" + ); + } +} + +#[test] +fn git_documentation_links_reject_unsafe_or_indirect_targets() { + for target in [ + &b"../../outside"[..], + b"/outside", + b"../missing", + b"../alias", + b"../directory", + b"../README.md\0", + b"..\\README.md", + b"../README.md\n", + b"../missing/../README.md", + b"../alias/README.md", + b"../README.md/.", + b"../directory/..", + ] { + let built = documentation_links(target); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + assert!( + run(&locked_core(), &material).outcome.is_err(), + "should reject an unsafe or indirect file link" + ); + } +} + +#[test] +fn git_manifest_links_are_rejected_during_package_discovery() { + let objects = Objects::new(); + let manifest = objects.blob(PLAIN.as_bytes()); + let target = objects.blob(b"manifest.toml"); + let root = objects.tree(&[ + ("120000", "Cargo.toml", &target), + ("100644", "manifest.toml", &manifest), + ]); + let material = objects.material(SELECTOR, REVISION, &root); + assert!( + rejection(run(&locked_core(), &material)).contains("manifest aliases"), + "should reject an aliased manifest before using its bytes" + ); +} + +#[test] +fn git_file_link_targets_are_rehashed_before_copying() { + let built = documentation_links(b"../README.md"); + let target = built.objects.blob(b"example readme bytes\n"); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + fs::write( + material + .object_store() + .join(&target[..2]) + .join(&target[2..]), + b"changed object bytes", + ) + .expect("should corrupt the target object"); + assert!( + run(&locked_core(), &material).outcome.is_err(), + "should reject a target changed after object verification" + ); +} + +fn rejection(run: Run) -> String { + run.outcome.expect_err("should reject the Git package") +} + +#[test] +fn plain_manifest_package_is_vendored_without_derivation() { + let built = workspace(PLAIN, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let result = run(&locked_core(), &material); + let materials = result.outcome.expect("should vendor the plain package"); + let index = serde_json::to_value(materials.packages()).expect("should encode Git index"); + let identity = &index["example-core"]; + assert_eq!( + identity["source"], + format!("{SELECTOR}#{REVISION}"), + "should preserve the complete locked source" + ); + assert_eq!( + identity["origin"]["kind"], "git", + "should identify verified Git originals" + ); + assert_eq!( + identity["origin"]["repository_tree"], built.root_tree, + "should bind verified repository tree" + ); + assert_eq!( + identity["origin"]["committed_manifest_blob"], built.manifest_blob, + "should retain the exact committed manifest blob" + ); + assert_eq!( + identity["origin"]["committed_manifest_sha256"], + hex::encode(Sha256::digest(PLAIN.as_bytes())), + "should bind original manifest bytes" + ); + assert_eq!( + identity["origin"]["package_path"], "crates/core", + "should bind package location within repository" + ); + assert!( + identity["origin"]["normalized_manifest_sha256"].is_null(), + "should not invent normalization for a plain manifest" + ); + let package = result.root.join("vendor/example-core"); + assert_eq!( + fs::read(package.join("Cargo.toml")).expect("should read manifest"), + PLAIN.as_bytes(), + "should keep the committed manifest" + ); + assert_eq!( + fs::read(package.join("src/lib.rs")).expect("should read source"), + b"pub fn core() {}\n", + "should copy the tree" + ); + assert_eq!( + fs::metadata(package.join("run.sh")) + .expect("should stat script") + .permissions() + .mode() + & 0o7777, + 0o755, + "should keep the executable bit" + ); + assert!( + !package.join("nested").exists(), + "should skip nested packages" + ); + assert!( + !package.join("Cargo.toml.orig").exists(), + "should not invent an original" + ); + let checksum: Value = serde_json::from_slice( + &fs::read(package.join(".cargo-checksum.json")).expect("should read checksums"), + ) + .expect("should be JSON"); + assert_eq!( + checksum, + json!({ + "files": { + "Cargo.toml": hex::encode(Sha256::digest(PLAIN.as_bytes())), + "run.sh": hex::encode(Sha256::digest(b"#!/bin/sh\n")), + "src/lib.rs": hex::encode(Sha256::digest(b"pub fn core() {}\n")), + }, + "package": null, + }), + "should list files and a null package checksum like cargo vendor" + ); + assert!( + materials.normalized_manifest_digests().is_empty() + && materials.derived_manifest_digests().is_empty(), + "should record no normalized or derived manifest" + ); + assert_eq!( + materials.git_sources().len(), + 1, + "should list the locked Git source" + ); + assert_eq!( + materials.git_sources()[0].key(), + SELECTOR, + "should key the source by its selector" + ); + materials.rehash().expect("should rehash the vendor tree"); +} + +#[test] +fn workspace_inheritance_is_normalized_by_the_producer_from_the_verified_tree() { + let built = workspace(INHERITING, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let accepted = run(&locked_core(), &material); + let materials = accepted + .outcome + .expect("should normalize the inheriting manifest itself"); + let package = accepted.root.join("vendor/example-core"); + let written = fs::read(package.join("Cargo.toml")).expect("should read manifest"); + let parsed: toml::Table = + toml::from_str(std::str::from_utf8(&written).expect("should write a UTF-8 manifest")) + .expect("should write TOML"); + let expected: toml::Table = toml::from_str( + "[package]\nname = \"example-core\"\nversion = \"0.4.0\"\nedition = \"2021\"\n\n[dependencies.example-dep]\nversion = \"1\"\nfeatures = [\"base\", \"extra\"]\n", + ) + .expect("should parse the expected manifest"); + assert_eq!( + parsed, expected, + "should resolve inherited fields and dependencies from the workspace root" + ); + assert_eq!( + fs::read(package.join("Cargo.toml.orig")).expect("should read original"), + INHERITING.as_bytes(), + "should keep the committed manifest as the original" + ); + let digest = hex::encode(Sha256::digest(&written)); + let index = serde_json::to_value(materials.packages()).expect("should encode normalized index"); + let identity = &index["example-core"]; + assert_eq!( + identity["prepared_manifest_sha256"], digest, + "should identify the prepared normalized manifest" + ); + assert_eq!( + identity["origin"]["normalized_manifest_sha256"], digest, + "should retain reviewed normalization identity" + ); + assert_eq!( + identity["origin"]["committed_manifest_sha256"], + hex::encode(Sha256::digest(INHERITING.as_bytes())), + "should retain distinct original bytes" + ); + assert_eq!( + materials.normalized_manifest_digests().get("example-core"), + Some(&digest), + "should record the producer-normalized manifest digest" + ); + assert!( + materials.derived_manifest_digests().is_empty(), + "should record no caller-derived manifest" + ); + let checksum: Value = serde_json::from_slice( + &fs::read(package.join(".cargo-checksum.json")).expect("should read checksums"), + ) + .expect("should be JSON"); + assert_eq!( + checksum["files"]["Cargo.toml"], + json!(digest), + "should checksum the normalized manifest" + ); + assert_eq!( + checksum["files"]["Cargo.toml.orig"], + json!(hex::encode(Sha256::digest(INHERITING.as_bytes()))), + "should checksum the original" + ); + + let again = run(&locked_core(), &material); + again.outcome.expect("should normalize again"); + assert_eq!( + fs::read(again.root.join("vendor/example-core/Cargo.toml")) + .expect("should read the second manifest"), + written, + "should normalize to identical bytes every time" + ); +} + +#[test] +fn unresolvable_workspace_inheritance_fails_closed() { + let cases: [(&str, Workspace, &str, &str); 4] = [ + ( + "no workspace root in the tree", + workspace_with_root(INHERITING, None, &[]), + "0.4.0", + "no workspace root encloses it", + ), + ( + "root without the inherited dependency", + workspace_with_root( + INHERITING, + Some( + "[workspace]\n\n[workspace.package]\nversion = \"0.4.0\"\nedition = \"2021\"\n", + ), + &[], + ), + "0.4.0", + "which is not defined", + ), + ( + "inherited version other than the lock", + workspace(INHERITING, &[]), + "0.5.0", + "does not declare package example-core 0.5.0", + ), + ( + "member patch section", + workspace( + &format!("{INHERITING}\n[patch.crates-io]\nexample-dep = {{ path = \"x\" }}\n"), + &[], + ), + "0.4.0", + "declares [patch]", + ), + ]; + for (label, built, version, expected) in cases { + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let locked = locked(&[], vec![git_package("example-core", version)]); + let message = rejection(run(&locked, &material)); + assert!( + message.contains(expected), + "should reject {label}: {message}" + ); + } +} + +#[test] +fn package_identity_and_root_must_be_unambiguous() { + let built = workspace(PLAIN, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let other_version = locked(&[], vec![git_package("example-core", "0.5.0")]); + assert!( + rejection(run(&other_version, &material)).contains("declares example-core 0.4.0"), + "should reject a manifest whose version differs from the lock" + ); + let unknown = locked(&[], vec![git_package("example-missing", "0.4.0")]); + assert!( + rejection(run(&unknown, &material)).contains("no Git directory declares"), + "should reject a package no directory declares" + ); + + let objects = Objects::new(); + let blob = objects.blob(PLAIN.as_bytes()); + let first = objects.tree(&[("100644", "Cargo.toml", &blob)]); + let second = objects.tree(&[("100644", "Cargo.toml", &blob)]); + let root = objects.tree(&[("40000", "a", &first), ("40000", "b", &second)]); + let material = objects.material(SELECTOR, REVISION, &root); + assert!( + rejection(run(&locked_core(), &material)).contains("ambiguous Git package roots"), + "should reject two directories declaring the package" + ); +} + +#[test] +fn tree_entries_that_would_alias_the_manifest_or_escape_are_rejected() { + let built = workspace(INHERITING, &[("Cargo.toml.orig", b"committed orig\n")]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let conflict = rejection(run(&locked_core(), &material)); + assert!( + conflict.contains("repeated"), + "should reject a committed Cargo.toml.orig beside a normalized manifest: {conflict}" + ); + + for (label, mode, name) in [ + ("symlink", "120000", "link"), + ("submodule", "160000", "module"), + ("group-writable file", "100664", "odd"), + ("dotdot name", "100644", ".."), + ("backslash name", "100644", "a\\b"), + ("git name", "100644", ".GIT"), + ] { + let objects = Objects::new(); + let manifest = objects.blob(PLAIN.as_bytes()); + let extra = objects.blob(b"x"); + let tree = objects.tree(&[("100644", "Cargo.toml", &manifest), (mode, name, &extra)]); + let material = objects.material(SELECTOR, REVISION, &tree); + assert!( + run(&locked_core(), &material).outcome.is_err(), + "should reject a {label} entry" + ); + } +} + +#[test] +fn git_inputs_must_be_exactly_the_locked_bound_closures() { + let built = workspace(PLAIN, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let cases: [(&str, GitPackage); 3] = [ + ( + "selector", + GitPackage { + selector: "git+https://example.com/org/other?tag=v1".to_owned(), + ..git_package("example-core", "0.4.0") + }, + ), + ( + "revision", + GitPackage { + revision: "4".repeat(40), + ..git_package("example-core", "0.4.0") + }, + ), + ( + "tag", + GitPackage { + selector: "git+https://example.com/org/workspace?tag=v2".to_owned(), + ..git_package("example-core", "0.4.0") + }, + ), + ]; + for (label, package) in cases { + let locked = locked(&[], vec![package]); + assert!( + rejection(run(&locked, &material)).contains("not bound to its locked source"), + "should reject a closure with another {label}" + ); + } + + let output = temporary(); + let locked = locked_core(); + let none: [GitVendorInput<'_>; 0] = []; + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: &Default::default(), + git: &none + }, + output.path(), + deadline(), + &cancellation() + ) + .expect_err("should reject a missing closure") + .to_string() + .contains("Original release material"), + "should reject a locked Git package without a closure" + ); + let twice = [ + GitVendorInput { + material: &material, + name: "example-core", + version: "0.4.0", + }, + GitVendorInput { + material: &material, + name: "example-core", + version: "0.4.0", + }, + ]; + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: &Default::default(), + git: &twice + }, + output.path(), + deadline(), + &cancellation() + ) + .is_err(), + "should reject an extra closure" + ); +} + +#[test] +fn store_objects_are_rehashed_before_use() { + let built = workspace(PLAIN, &[]); + let store = built.objects.store.path().to_owned(); + let blob = &built.manifest_blob; + let object = store.join(&blob[..2]).join(&blob[2..]); + let forged_store = Objects::new(); + let forged = forged_store + .blob(b"[package]\nname = \"example-core\"\nversion = \"0.4.0\"\nedition = \"2018\"\n"); + let other = forged_store + .store + .path() + .join(&forged[..2]) + .join(&forged[2..]); + fs::copy(&other, &object).expect("should swap the committed manifest object for forged bytes"); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let message = rejection(run(&locked_core(), &material)); + assert!( + message.contains("do not match their identity"), + "should reject an edited store object: {message}" + ); + + let built = workspace(PLAIN, &[]); + let lib = built + .objects + .store + .path() + .read_dir() + .expect("should list store") + .count(); + assert!(lib > 3, "should have written several objects"); + let missing_root = built.objects.material(SELECTOR, REVISION, &"5".repeat(40)); + assert!( + rejection(run(&locked_core(), &missing_root)).contains("cannot be opened"), + "should reject a root tree absent from the store" + ); +} + +#[test] +fn git_package_directory_is_its_bare_name_and_never_shared() { + let built = workspace(PLAIN, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let accepted = run(&locked_core(), &material); + accepted.outcome.expect("should vendor the Git package"); + assert!( + accepted + .root + .join("vendor/example-core/Cargo.toml") + .is_file() + && !accepted.root.join("vendor/example-core-0.4.0").exists(), + "should name the Git package directory after the package alone" + ); + + let registry_crate = plain_crate("example", "core"); + let locked = locked( + &[("example", "core", ®istry_crate)], + vec![git_package("example-core", "0.4.0")], + ); + let archive_directory = temporary(); + let registry = archives( + archive_directory.path(), + &[("example", "core", ®istry_crate)], + ); + let output = temporary(); + let inputs = [GitVendorInput { + material: &material, + name: "example-core", + version: "0.4.0", + }]; + let error = locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &inputs, + }, + output.path(), + deadline(), + &cancellation(), + ) + .expect_err("should refuse a Git directory that a registry crate already holds"); + assert!( + format!("{error:?}").contains("cannot be created exclusively"), + "should fail closed on the shared directory: {error:?}" + ); +} + +#[test] +fn git_packages_sharing_a_name_fail_closed() { + let versions = ["0.4.0", "0.5.0"]; + let materials = versions.map(|version| { + let root = WORKSPACE_ROOT.replace("0.4.0", version); + let built = workspace_with_root(INHERITING, Some(&root), &[]); + built.objects.material(SELECTOR, REVISION, &built.root_tree) + }); + let locked = locked( + &[], + versions + .iter() + .map(|version| git_package("example-core", version)) + .collect(), + ); + let inputs: Vec> = versions + .iter() + .zip(&materials) + .map(|(version, material)| GitVendorInput { + material, + name: "example-core", + version, + }) + .collect(); + let output = temporary(); + let error = locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: &Default::default(), + git: &inputs, + }, + output.path(), + deadline(), + &cancellation(), + ) + .expect_err("should refuse a second Git package in the same bare-name directory"); + assert!( + format!("{error:?}").contains("cannot be created exclusively"), + "should fail closed on the shared directory: {error:?}" + ); +} + +#[test] +fn ancestor_walk_reads_every_manifest_above_the_package_nearest_first() { + let objects = Objects::new(); + let core_manifest = objects.blob(INHERITING.as_bytes()); + let core = objects.tree(&[("100644", "Cargo.toml", &core_manifest)]); + let umbrella = b"[package]\nname = \"example-umbrella\"\nversion = \"9.9.9\"\n"; + let umbrella_manifest = objects.blob(umbrella); + let crates = objects.tree(&[ + ("100644", "Cargo.toml", &umbrella_manifest), + ("40000", "core", &core), + ]); + let root_manifest = objects.blob(WORKSPACE_ROOT.as_bytes()); + let root_tree = objects.tree(&[ + ("100644", "Cargo.toml", &root_manifest), + ("40000", "crates", &crates), + ]); + let store = Store::new(objects.store.path()); + let package = PackageRoot { + tree: core.clone(), + manifest: core_manifest.clone(), + path: "crates/core".to_owned(), + }; + let found = ancestor_manifests(&store, &root_tree, &package) + .expect("should walk the verified package path"); + let seen: Vec<(&str, &[u8])> = found + .iter() + .map(|ancestor| (ancestor.directory.as_str(), ancestor.bytes.as_slice())) + .collect(); + assert_eq!( + seen, + [ + ("crates", umbrella.as_slice()), + ("", WORKSPACE_ROOT.as_bytes()), + ], + "should list the intermediate package manifest before the workspace root" + ); + + let elsewhere = PackageRoot { + tree: crates.clone(), + ..package.clone() + }; + let wrong_tree = ancestor_manifests(&store, &root_tree, &elsewhere) + .expect_err("should refuse a path that leads to another tree"); + assert!( + format!("{wrong_tree:?}").contains("does not lead to the package tree"), + "should report the mismatched package tree: {wrong_tree:?}" + ); + let absent = PackageRoot { + path: "crates/absent".to_owned(), + ..package + }; + let missing = ancestor_manifests(&store, &root_tree, &absent) + .expect_err("should refuse a path absent from the tree"); + assert!( + format!("{missing:?}").contains("is not a tree in the verified closure"), + "should report the missing directory: {missing:?}" + ); +} + +#[test] +fn git_targets_that_leave_the_package_are_refused() { + let escaping = format!("{PLAIN}build = \"../shared/build.rs\"\n"); + let built = workspace(&escaping, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let message = rejection(run(&locked_core(), &material)); + assert!( + message.contains("leaves the vendored package directory"), + "should refuse a build script outside the package: {message}" + ); +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs new file mode 100644 index 000000000..504e178ca --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs @@ -0,0 +1,565 @@ +//! Pure tests of producer-owned manifest normalization over fictional workspaces. + +use super::super::normalize::{ + AncestorManifest, INHERITED_PACKAGE_FIELDS, PackageManifest, check_target_paths, + normalize_manifest, relocate, +}; + +const ROOT: &str = r#"[workspace] +members = ["crates/example-core", "crates/example-sibling"] +exclude = ["examples/example-demo"] + +[workspace.package] +authors = ["Example Team "] +categories = ["example-category"] +description = "Fictional workspace description" +documentation = "https://docs.example.com/example-core" +edition = "2024" +exclude = ["fixtures/"] +homepage = "https://example.com" +include = ["src/**", "Cargo.toml"] +keywords = ["example"] +license = "Apache-2.0" +publish = false +repository = "https://example.com/org/workspace" +rust-version = "1.85" +version = "0.4.0" + +[workspace.dependencies] +example-plain = "1" +example-featured = { version = "2", features = ["base"] } +example-quiet = { version = "3", default-features = false, features = ["core"] } +example-sibling = { path = "crates/example-sibling", default-features = false } +example-renamed = { version = "4", package = "example-original" } + +[workspace.lints.rust] +unsafe_code = "deny" + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +"#; + +const MEMBER: &str = r#"[package] +name = "example-core" +authors.workspace = true +categories.workspace = true +description.workspace = true +documentation.workspace = true +edition.workspace = true +exclude.workspace = true +homepage.workspace = true +include.workspace = true +keywords.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version = { workspace = true } + +[features] +default = ["example-featured/extra"] + +[dependencies] +example-plain = { workspace = true } +example-featured = { workspace = true, features = ["extra", "base"], optional = true } +example-quiet = { workspace = true, default-features = true } +example-sibling = { workspace = true, features = ["sibling-extra"] } +example-own = { path = "../example-own", version = "5" } + +[target.'cfg(unix)'.dependencies] +example-renamed = { workspace = true } + +[dev-dependencies] +example-plain = { workspace = true, features = ["testing"] } + +[build-dependencies] +example-plain.workspace = true + +[lints] +workspace = true +"#; + +const EXPECTED: &str = r#"[package] +name = "example-core" +authors = ["Example Team "] +categories = ["example-category"] +description = "Fictional workspace description" +documentation = "https://docs.example.com/example-core" +edition = "2024" +exclude = ["fixtures/"] +homepage = "https://example.com" +include = ["src/**", "Cargo.toml"] +keywords = ["example"] +license = "Apache-2.0" +publish = false +repository = "https://example.com/org/workspace" +rust-version = "1.85" +version = "0.4.0" + +[features] +default = ["example-featured/extra"] + +[dependencies] +example-plain = { version = "1" } +example-featured = { version = "2", features = ["base", "extra"], optional = true } +example-quiet = { version = "3", default-features = true, features = ["core"] } +example-sibling = { path = "../example-sibling", default-features = false, features = ["sibling-extra"] } +example-own = { path = "../example-own", version = "5" } + +[target.'cfg(unix)'.dependencies] +example-renamed = { version = "4", package = "example-original" } + +[dev-dependencies] +example-plain = { version = "1", features = ["testing"] } + +[build-dependencies] +example-plain = { version = "1" } + +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +"#; + +fn root_ancestors(root: &str) -> Vec { + vec![AncestorManifest { + directory: String::new(), + bytes: root.as_bytes().to_vec(), + }] +} + +fn normalize( + member: &str, + version: &str, + ancestors: &[AncestorManifest], +) -> Result, String> { + normalize_manifest( + &PackageManifest { + name: "example-core", + version, + directory: "crates/example-core", + bytes: member.as_bytes(), + }, + ancestors, + ) + .map_err(|error| format!("{error:?}")) +} + +fn table(bytes: &[u8]) -> toml::Table { + toml::from_str(std::str::from_utf8(bytes).expect("should be UTF-8")).expect("should be TOML") +} + +#[test] +fn every_supported_inheritance_kind_resolves_against_the_workspace_root() { + let normalized = + normalize(MEMBER, "0.4.0", &root_ancestors(ROOT)).expect("should normalize the member"); + assert_eq!( + table(&normalized), + table(EXPECTED.as_bytes()), + "should resolve package fields, dependencies, features, defaults, paths and lints" + ); + let text = String::from_utf8(normalized).expect("should be UTF-8"); + assert!( + !text.contains("workspace = true") && !text.contains("[workspace"), + "should leave no inheritance marker and no workspace table" + ); + assert!( + text.starts_with("# Normalized by trusted-server-release"), + "should say who normalized the manifest" + ); +} + +#[test] +fn every_listed_package_field_is_exercised() { + let member = table(MEMBER.as_bytes()); + let package = member["package"] + .as_table() + .expect("should hold a package table"); + for field in INHERITED_PACKAGE_FIELDS { + assert!( + package.get(field).and_then(toml::Value::as_table).is_some(), + "should inherit package.{field} in the fixture" + ); + } +} + +#[test] +fn normalization_is_deterministic_and_independent_of_input_key_order() { + let ancestors = root_ancestors(ROOT); + let first = normalize(MEMBER, "0.4.0", &ancestors).expect("should normalize"); + let second = normalize(MEMBER, "0.4.0", &ancestors).expect("should normalize again"); + assert_eq!( + first, second, + "should produce identical bytes for equal input" + ); + let reordered = MEMBER.replace( + "authors.workspace = true\ncategories.workspace = true\n", + "categories.workspace = true\nauthors.workspace = true\n", + ); + assert_ne!(reordered, MEMBER, "should actually reorder the fixture"); + assert_eq!( + normalize(&reordered, "0.4.0", &ancestors).expect("should normalize the reordered input"), + first, + "should sort keys so input order cannot change the bytes" + ); +} + +#[test] +fn nearest_workspace_root_is_found_past_plain_ancestor_manifests() { + let ancestors = vec![ + AncestorManifest { + directory: "crates".to_owned(), + bytes: b"[package]\nname = \"example-umbrella\"\nversion = \"9.9.9\"\n".to_vec(), + }, + AncestorManifest { + directory: String::new(), + bytes: ROOT.as_bytes().to_vec(), + }, + ]; + let normalized = normalize(MEMBER, "0.4.0", &ancestors).expect("should skip plain manifests"); + assert_eq!( + table(&normalized), + table(EXPECTED.as_bytes()), + "should resolve against the enclosing workspace root" + ); +} + +#[test] +fn a_package_that_is_its_own_workspace_root_drops_the_workspace_table() { + let member = "[package]\nname = \"example-core\"\nversion.workspace = true\n\n[workspace]\n\n[workspace.package]\nversion = \"0.4.0\"\n\n[workspace.dependencies]\nexample-plain = \"1\"\n\n[dependencies]\nexample-plain.workspace = true\n"; + let normalized = normalize_manifest( + &PackageManifest { + name: "example-core", + version: "0.4.0", + directory: "", + bytes: member.as_bytes(), + }, + &[], + ) + .expect("should resolve against its own workspace table"); + assert_eq!( + table(&normalized), + table(b"[package]\nname = \"example-core\"\nversion = \"0.4.0\"\n\n[dependencies]\nexample-plain = { version = \"1\" }\n"), + "should drop [workspace] and resolve inheritance" + ); +} + +#[test] +fn workspace_paths_are_relocated_relative_to_the_package() { + let cases = [ + ( + "", + "crates/example-sibling", + "crates/example-core", + "../example-sibling", + ), + ( + "", + "./crates/deep/example-leaf", + "crates/example-core", + "../deep/example-leaf", + ), + ( + "tools", + "../crates/example-sibling", + "crates/example-core", + "../example-sibling", + ), + ( + "", + "crates/example-core/inner", + "crates/example-core", + "inner", + ), + ("", "example-top", "example-top", "."), + ]; + for (workspace, path, package, expected) in cases { + assert_eq!( + relocate(workspace, path, package).expect("should relocate the path"), + expected, + "should rewrite {path} from {workspace:?} for {package}" + ); + } + for (path, label) in [ + ("/example/absolute", "absolute"), + ("../outside", "escaping"), + ("", "empty"), + ("crates\\example", "backslash"), + ] { + assert!( + relocate("", path, "crates/example-core").is_err(), + "should refuse a {label} workspace path" + ); + } +} + +#[test] +fn unsupported_or_unresolvable_inheritance_fails_closed() { + let member_with = |extra: &str| format!("{MEMBER}{extra}"); + let replace = |from: &str, to: &str| MEMBER.replacen(from, to, 1); + let cases: Vec<(&str, String, String, &str)> = vec![ + ( + "inherited readme", + replace("authors.workspace = true", "readme.workspace = true"), + ROOT.to_owned(), + "workspace-relative path", + ), + ( + "inherited license-file", + replace("authors.workspace = true", "license-file.workspace = true"), + ROOT.to_owned(), + "workspace-relative path", + ), + ( + "unknown inheritable key", + replace("authors.workspace = true", "links.workspace = true"), + ROOT.to_owned(), + "not an inheritable field", + ), + ( + "workspace = false", + replace("authors.workspace = true", "authors.workspace = false"), + ROOT.to_owned(), + "exactly { workspace = true }", + ), + ( + "missing workspace.package field", + MEMBER.to_owned(), + ROOT.replace("homepage = \"https://example.com\"\n", ""), + "does not define", + ), + ( + "member key beside workspace", + replace( + "example-plain = { workspace = true }", + "example-plain = { workspace = true, version = \"1\" }", + ), + ROOT.to_owned(), + "sets version beside workspace = true", + ), + ( + "ignored default-features = false", + replace( + "example-plain = { workspace = true }", + "example-plain = { workspace = true, default-features = false }", + ), + ROOT.to_owned(), + "default-features = false", + ), + ( + "workspace git dependency", + MEMBER.to_owned(), + ROOT.replace( + "example-plain = \"1\"", + "example-plain = { git = \"https://example.com/org/plain\" }", + ), + "sets git; unsupported", + ), + ( + "workspace optional dependency", + MEMBER.to_owned(), + ROOT.replace( + "example-plain = \"1\"", + "example-plain = { version = \"1\", optional = true }", + ), + "sets optional; unsupported", + ), + ( + "missing workspace dependency", + MEMBER.to_owned(), + ROOT.replace("example-plain = \"1\"\n", ""), + "which is not defined", + ), + ( + "missing workspace lints", + MEMBER.to_owned(), + ROOT.split("[workspace.lints.rust]") + .next() + .unwrap_or_default() + .to_owned(), + "[workspace.lints], which is not defined", + ), + ( + "replace section", + member_with("\n[replace]\n\"example-plain:1.0.0\" = { path = \"x\" }\n"), + ROOT.to_owned(), + "declares [replace]", + ), + ( + "package workspace pointer", + replace( + "name = \"example-core\"", + "name = \"example-core\"\nworkspace = \"../..\"", + ), + ROOT.to_owned(), + "package.workspace pointers", + ), + ( + "legacy dependency spelling", + member_with("\n[dev_dependencies]\nexample-plain = \"1\"\n"), + ROOT.to_owned(), + "legacy spelling", + ), + ( + "excluded package", + MEMBER.to_owned(), + ROOT.replace("examples/example-demo", "crates/example-core"), + "excluded from its nearest workspace root", + ), + ( + "inherited badges", + member_with("\n[badges]\nworkspace = true\n"), + ROOT.to_owned(), + "still inherits", + ), + ( + "other name", + replace("name = \"example-core\"", "name = \"example-other\""), + ROOT.to_owned(), + "does not name package example-core", + ), + ]; + for (label, member, root, expected) in cases { + let message = normalize(&member, "0.4.0", &root_ancestors(&root)) + .expect_err("should refuse the manifest"); + assert!( + message.contains(expected), + "should reject {label} with {expected:?}: {message}" + ); + } +} + +#[test] +fn missing_root_and_version_mismatch_fail_closed() { + let missing = normalize(MEMBER, "0.4.0", &[]).expect_err("should need a workspace root"); + assert!( + missing.contains("no workspace root encloses it"), + "should report the missing workspace root: {missing}" + ); + let plain_only = vec![AncestorManifest { + directory: String::new(), + bytes: b"[package]\nname = \"example-top\"\nversion = \"1.0.0\"\n".to_vec(), + }]; + assert!( + normalize(MEMBER, "0.4.0", &plain_only) + .expect_err("should need a [workspace] table") + .contains("no workspace root encloses it"), + "should not treat a plain ancestor manifest as a workspace root" + ); + let mismatch = + normalize(MEMBER, "0.5.0", &root_ancestors(ROOT)).expect_err("should check the version"); + assert!( + mismatch.contains("does not declare package example-core 0.5.0"), + "should refuse an inherited version other than the locked one: {mismatch}" + ); + let broken = normalize(MEMBER, "0.4.0", &root_ancestors("[workspace")) + .expect_err("should refuse an unparsable root"); + assert!( + broken.contains("TOML parse error"), + "should report an unparsable workspace root: {broken}" + ); +} + +#[test] +fn ancestor_pointers_and_non_manifests_are_refused() { + let nearer = |bytes: &[u8]| { + vec![ + AncestorManifest { + directory: "crates".to_owned(), + bytes: bytes.to_vec(), + }, + AncestorManifest { + directory: String::new(), + bytes: ROOT.as_bytes().to_vec(), + }, + ] + }; + let pointer = normalize( + MEMBER, + "0.4.0", + &nearer( + b"[package] +name = \"example-umbrella\"\nversion = \"9.9.9\"\nworkspace = \"../elsewhere\"\n", + ), + ) + .expect_err("should refuse an ancestor that points to another root"); + assert!( + pointer.contains("points to another workspace root"), + "should report the workspace pointer instead of skipping it: {pointer}" + ); + let neither = normalize(MEMBER, "0.4.0", &nearer(b"[profile.release]\nlto = true\n")) + .expect_err("should refuse an ancestor that is neither package nor workspace"); + assert!( + neither.contains("neither [package] nor [workspace]"), + "should report the unrecognized ancestor manifest: {neither}" + ); +} + +#[test] +fn matching_disabled_default_features_stay_disabled() { + let member = MEMBER.replace( + "example-quiet = { workspace = true, default-features = true }", + "example-quiet = { workspace = true, default-features = false }", + ); + let normalized = + normalize(&member, "0.4.0", &root_ancestors(ROOT)).expect("should accept matching false"); + assert_eq!( + table(&normalized)["dependencies"]["example-quiet"], + toml::Value::Table(table( + b"version = \"3\"\ndefault-features = false\nfeatures = [\"core\"]\n" + )), + "should keep default features off when both sides turn them off" + ); +} + +#[test] +fn workspace_side_feature_repeats_are_dropped() { + let root = ROOT.replace( + "example-featured = { version = \"2\", features = [\"base\"] }", + "example-featured = { version = \"2\", features = [\"base\", \"extra\", \"base\"] }", + ); + let normalized = + normalize(MEMBER, "0.4.0", &root_ancestors(&root)).expect("should normalize the member"); + assert_eq!( + table(&normalized)["dependencies"]["example-featured"]["features"], + toml::Value::Array(vec![ + toml::Value::String("base".to_owned()), + toml::Value::String("extra".to_owned()), + ]), + "should keep each feature once, in first-seen order" + ); +} + +#[test] +fn target_paths_must_stay_inside_the_package() { + let manifest = |extra: &str| -> toml::Table { + table( + format!("[package]\nname = \"example-core\"\nversion = \"0.4.0\"\n{extra}").as_bytes(), + ) + }; + for accepted in [ + "", + "build = false\n", + "build = \"tools/build.rs\"\n", + "\n[lib]\npath = \"src/lib.rs\"\n", + "\n[[bin]]\nname = \"example\"\npath = \"src/bin/example.rs\"\n", + ] { + check_target_paths(&manifest(accepted)) + .unwrap_or_else(|error| panic!("should accept {accepted:?}: {error:?}")); + } + for refused in [ + "build = \"../build.rs\"\n", + "\n[lib]\npath = \"../shared/lib.rs\"\n", + "\n[[bin]]\nname = \"example\"\npath = \"/example/main.rs\"\n", + "\n[[example]]\nname = \"demo\"\npath = \"examples/../../demo.rs\"\n", + "\n[[test]]\nname = \"suite\"\npath = \"..\\\\suite.rs\"\n", + "\n[[bench]]\nname = \"speed\"\npath = \"../benches/speed.rs\"\n", + "\n[lib]\npath = 7\n", + ] { + assert!( + check_target_paths(&manifest(refused)).is_err(), + "should refuse {refused:?}" + ); + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs new file mode 100644 index 000000000..203392655 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs @@ -0,0 +1,493 @@ +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::support::*; +use crate::materials::{LockedMaterials, PreparedCargoMaterials, VendorInputs}; + +struct Prepared { + _archives: tempfile::TempDir, + _output: tempfile::TempDir, + outcome: Result, + root: std::path::PathBuf, +} + +fn prepare(locked: &LockedMaterials, entries: &[(&str, &str, &[u8])]) -> Prepared { + prepare_with(locked, entries, |_| {}) +} + +/// Prepares from `entries`, letting `tweak` change the output root first. +fn prepare_with( + locked: &LockedMaterials, + entries: &[(&str, &str, &[u8])], + tweak: impl FnOnce(&Path), +) -> Prepared { + let archive_directory = temporary(); + let output = temporary(); + let registry = archives(archive_directory.path(), entries); + let root = output.path().to_owned(); + tweak(&root); + let outcome = locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[], + }, + &root, + deadline(), + &cancellation(), + ) + .map_err(|error| format!("{error:?}")); + Prepared { + _archives: archive_directory, + _output: output, + outcome, + root, + } +} + +fn two_crates() -> (Vec, Vec) { + ( + plain_crate("example-one", "1.0.0"), + plain_crate("example-two", "0.3.1"), + ) +} + +#[test] +fn registry_crates_become_directory_source_packages_with_cargo_checksums() { + let (one, two) = two_crates(); + let entries: [(&str, &str, &[u8]); 2] = [ + ("example-one", "1.0.0", &one), + ("example-two", "0.3.1", &two), + ]; + let locked = locked(&entries, Vec::new()); + let prepared = prepare(&locked, &entries); + let materials = prepared.outcome.expect("should prepare the vendor tree"); + let vendor = materials.vendor_dir(); + let index = serde_json::to_value(materials.packages()).expect("should encode verified index"); + let identity = &index["example-one-1.0.0"]; + assert_eq!( + identity["name"], "example-one", + "should retain locked package name" + ); + assert_eq!( + identity["version"], "1.0.0", + "should retain locked package version" + ); + assert_eq!( + identity["origin"]["kind"], "registry", + "should identify original source class" + ); + assert_eq!( + identity["origin"]["archive_sha256"], + hex::encode(Sha256::digest(&one)), + "should bind the exact verified original archive" + ); + assert_eq!( + identity["prepared_manifest_sha256"], + hex::encode(Sha256::digest(manifest("example-one", "1.0.0"))), + "should bind the manifest Cargo actually reads" + ); + assert_eq!( + materials.packages().len(), + 2, + "should index exactly the prepared packages" + ); + assert_eq!( + vendor, + prepared.root.join("vendor"), + "should place packages under vendor" + ); + let mut names: Vec = fs::read_dir(vendor) + .expect("should list vendor") + .map(|entry| { + entry + .expect("should read entry") + .file_name() + .into_string() + .expect("should be UTF-8") + }) + .collect(); + names.sort(); + assert_eq!( + names, + ["example-one-1.0.0", "example-two-0.3.1"], + "should create one directory per crate" + ); + + let package = vendor.join("example-one-1.0.0"); + let manifest = manifest("example-one", "1.0.0"); + let expected = json!({ + "files": { + "Cargo.toml": hex::encode(Sha256::digest(&manifest)), + "build.sh": hex::encode(Sha256::digest(b"#!/bin/sh\n")), + "src/lib.rs": hex::encode(Sha256::digest(b"pub fn fictional() {}\n")), + }, + "package": hex::encode(Sha256::digest(&one)), + }); + let raw = fs::read(package.join(".cargo-checksum.json")).expect("should write checksum file"); + let parsed: Value = serde_json::from_slice(&raw).expect("should write JSON"); + assert_eq!( + parsed, expected, + "should list every file and the lock checksum" + ); + assert!( + String::from_utf8(raw) + .expect("should be UTF-8") + .starts_with("{\"files\":{"), + "should write files before package like cargo vendor" + ); + for (relative, mode) in [ + ("Cargo.toml", 0o644), + ("src/lib.rs", 0o644), + ("build.sh", 0o755), + ] { + let metadata = fs::symlink_metadata(package.join(relative)).expect("should stat file"); + assert_eq!( + metadata.permissions().mode() & 0o7777, + mode, + "should set mode of {relative}" + ); + assert_eq!(metadata.nlink(), 1, "should write unlinked {relative}"); + } + assert_eq!(materials.digest().len(), 64, "should digest the whole tree"); + assert_eq!( + materials.source_commit(), + SOURCE_COMMIT, + "should bind the approved source" + ); + assert_eq!( + materials.cargo_lock_sha256(), + LOCK_SHA256, + "should bind the lock digest" + ); + assert!( + materials.git_sources().is_empty(), + "should list no Git sources" + ); + materials + .rehash() + .expect("should rehash the untouched tree"); +} + +#[test] +fn preparation_is_deterministic_and_content_sensitive() { + let (one, two) = two_crates(); + let entries: [(&str, &str, &[u8]); 2] = [ + ("example-one", "1.0.0", &one), + ("example-two", "0.3.1", &two), + ]; + let locked_a = locked(&entries, Vec::new()); + let first = prepare(&locked_a, &entries) + .outcome + .expect("should prepare"); + let second = prepare(&locked_a, &entries) + .outcome + .expect("should prepare again"); + assert_eq!( + first.digest(), + second.digest(), + "should reproduce the digest across roots" + ); + assert_eq!( + serde_json::to_value(first.packages()).expect("should encode first index"), + serde_json::to_value(second.packages()).expect("should encode second index"), + "should keep original identities independent of staging paths" + ); + + let changed = plain_crate_with("example-one", "1.0.0", b"pub fn other() {}\n"); + let changed_entries: [(&str, &str, &[u8]); 2] = [ + ("example-one", "1.0.0", &changed), + ("example-two", "0.3.1", &two), + ]; + let changed_locked = locked(&changed_entries, Vec::new()); + let third = prepare(&changed_locked, &changed_entries) + .outcome + .expect("should prepare changed"); + assert_ne!( + first.digest(), + third.digest(), + "should change the digest with content" + ); + assert_ne!( + serde_json::to_value(first.packages()).expect("should encode original index"), + serde_json::to_value(third.packages()).expect("should encode changed index"), + "should bind changed original archives even when package names and versions match" + ); +} + +fn plain_crate_with(name: &str, version: &str, source: &[u8]) -> Vec { + crate_archive( + name, + version, + &[ + ("Cargo.toml", &manifest(name, version), 0o644), + ("src/lib.rs", source, 0o644), + ], + ) +} + +#[test] +fn rehash_detects_edits_additions_removals_and_extra_root_entries() { + let one = plain_crate("example-one", "1.0.0"); + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &one)]; + let locked = locked(&entries, Vec::new()); + let package = |root: &Path| root.join("vendor/example-one-1.0.0"); + type Tamper = fn(&Path); + let tampers: [(&str, Tamper); 6] = [ + ("edit", |root| { + let path = root.join("vendor/example-one-1.0.0/src/lib.rs"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("should chmod"); + fs::write(path, b"tampered").expect("should edit"); + }), + ("add", |root| { + fs::write(root.join("vendor/example-one-1.0.0/extra.rs"), b"x").expect("should add"); + }), + ("remove", |root| { + fs::remove_file(root.join("vendor/example-one-1.0.0/src/lib.rs")) + .expect("should remove"); + }), + ("chmod", |root| { + fs::set_permissions( + root.join("vendor/example-one-1.0.0/build.sh"), + fs::Permissions::from_mode(0o644), + ) + .expect("should chmod"); + }), + ("extra root entry", |root| { + fs::create_dir(root.join("cargo-home")).expect("should add root entry"); + }), + ("hard link", |root| { + fs::hard_link( + root.join("vendor/example-one-1.0.0/src/lib.rs"), + root.join("vendor/example-one-1.0.0/linked.rs"), + ) + .expect("should link"); + }), + ]; + for (label, tamper) in tampers { + let prepared = prepare(&locked, &entries); + let materials = prepared.outcome.expect("should prepare"); + assert!( + package(&prepared.root).is_dir(), + "should create the package directory" + ); + tamper(&prepared.root); + assert!( + materials.rehash().is_err(), + "should reject {label} after preparation" + ); + } +} + +#[test] +fn registry_inputs_must_be_exactly_the_locked_crates() { + let (one, two) = two_crates(); + let locked_both = locked( + &[ + ("example-one", "1.0.0", &one), + ("example-two", "0.3.1", &two), + ], + Vec::new(), + ); + let missing = prepare(&locked_both, &[("example-one", "1.0.0", &one)]); + assert!( + missing + .outcome + .expect_err("should reject a missing original") + .contains("exactly the locked"), + "should reject a missing archive" + ); + let locked_one = locked(&[("example-one", "1.0.0", &one)], Vec::new()); + let extra = prepare( + &locked_one, + &[ + ("example-one", "1.0.0", &one), + ("example-two", "0.3.1", &two), + ], + ); + assert!( + extra + .outcome + .expect_err("should reject an extra original") + .contains("exactly the locked"), + "should reject an unlocked archive" + ); + let wrong_version = prepare(&locked_one, &[("example-one", "1.0.1", &one)]); + assert!( + wrong_version.outcome.is_err(), + "should reject an archive for another version" + ); +} + +#[test] +fn archive_bytes_must_match_the_locked_checksum() { + let one = plain_crate("example-one", "1.0.0"); + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &one)]; + let locked = locked(&entries, Vec::new()); + let other = plain_crate_with("example-one", "1.0.0", b"different\n"); + let prepared = prepare(&locked, &[("example-one", "1.0.0", &other)]); + assert!( + prepared.outcome.is_err(), + "should reject bytes that do not match the lock checksum" + ); +} + +#[test] +fn manifests_must_declare_the_locked_identity_and_nothing_reserved() { + let cases: [(&str, Vec); 5] = [ + ( + "other name", + crate_archive( + "example-one", + "1.0.0", + &[("Cargo.toml", &manifest("example-other", "1.0.0"), 0o644)], + ), + ), + ( + "other version", + crate_archive( + "example-one", + "1.0.0", + &[("Cargo.toml", &manifest("example-one", "1.0.1"), 0o644)], + ), + ), + ( + "missing manifest", + crate_archive("example-one", "1.0.0", &[("src/lib.rs", b"x", 0o644)]), + ), + ( + "unparsable manifest", + crate_archive( + "example-one", + "1.0.0", + &[("Cargo.toml", b"[package", 0o644)], + ), + ), + ( + "reserved checksum file", + crate_archive( + "example-one", + "1.0.0", + &[ + ("Cargo.toml", &manifest("example-one", "1.0.0"), 0o644), + (".cargo-checksum.json", b"{}", 0o644), + ], + ), + ), + ]; + for (label, bytes) in cases { + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &bytes)]; + let locked = locked(&entries, Vec::new()); + assert!( + prepare(&locked, &entries).outcome.is_err(), + "should reject a crate with {label}" + ); + } +} + +#[test] +fn output_root_must_be_a_fresh_real_directory() { + let one = plain_crate("example-one", "1.0.0"); + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &one)]; + let locked = locked(&entries, Vec::new()); + let populated = prepare_with(&locked, &entries, |root| { + fs::write(root.join("leftover"), b"x").expect("should populate root"); + }); + assert!( + populated + .outcome + .expect_err("should reject a populated root") + .contains("already has entries"), + "should reject a root with prior entries" + ); + let archive_directory = temporary(); + let registry = archives(archive_directory.path(), &entries); + let real = archive_directory.path().join("real-root"); + fs::create_dir(&real).expect("should create real root"); + let alias = archive_directory.path().join("alias-root"); + std::os::unix::fs::symlink(&real, &alias).expect("should alias root"); + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[] + }, + &alias, + deadline(), + &cancellation(), + ) + .expect_err("should reject a symlinked root") + .to_string() + .contains("Original release material"), + "should reject a symlinked root as a material error" + ); + let missing = archive_directory.path().join("missing-root"); + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[] + }, + &missing, + deadline(), + &cancellation(), + ) + .is_err(), + "should reject a root that does not exist" + ); + let file = archive_directory.path().join("a-file"); + fs::write(&file, b"x").expect("should write file"); + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[] + }, + &file, + deadline(), + &cancellation(), + ) + .is_err(), + "should reject a root that is a file" + ); +} + +#[test] +fn cancelled_or_expired_attempt_prepares_nothing() { + let one = plain_crate("example-one", "1.0.0"); + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &one)]; + let locked = locked(&entries, Vec::new()); + let archive_directory = temporary(); + let registry = archives(archive_directory.path(), &entries); + let output = temporary(); + let token = cancellation(); + token.cancel(); + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[] + }, + output.path(), + deadline(), + &token, + ) + .is_err(), + "should reject a cancelled attempt" + ); + assert!( + fs::read_dir(output.path()) + .expect("should list root") + .next() + .is_none(), + "should write nothing for a cancelled attempt" + ); +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs new file mode 100644 index 000000000..23b2eeeae --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs @@ -0,0 +1,102 @@ +use crate::materials::cargo_vendor::GitSourceConfig; +use crate::materials::locked::parse_cargo; + +/// Selector, derived URL and derived reference of one locked Git source. +type Case = ( + &'static str, + &'static str, + Option<(&'static str, &'static str)>, +); + +#[test] +fn locked_selectors_map_to_exact_cargo_source_tables() { + let cases: [Case; 4] = [ + ( + "git+https://example.com/org/repo?tag=v1.2.3", + "https://example.com/org/repo", + Some(("tag", "v1.2.3")), + ), + ( + "git+https://example.com/org/repo?branch=release-1", + "https://example.com/org/repo", + Some(("branch", "release-1")), + ), + ( + "git+https://example.com/org/repo?rev=abc123", + "https://example.com/org/repo", + Some(("rev", "abc123")), + ), + ( + "git+https://example.com:8443/org/repo.git", + "https://example.com:8443/org/repo.git", + None, + ), + ]; + for (selector, url, reference) in cases { + let config = GitSourceConfig::parse(selector).expect("should parse the selector"); + assert_eq!( + config.key(), + selector, + "should keep the exact selector as the key" + ); + assert_eq!(config.url(), url, "should derive the repository URL"); + assert_eq!(config.reference(), reference, "should derive the reference"); + } + assert_eq!( + GitSourceConfig::parse("git+https://example.com/org/repo") + .expect("should parse") + .reference(), + None, + "should report the default branch without a reference" + ); +} + +#[test] +fn unusual_selectors_are_rejected() { + for selector in [ + "", + "https://example.com/org/repo?tag=v1", + "git+http://example.com/org/repo?tag=v1", + "git+ssh://example.com/org/repo", + "git+file:///local/repo", + "git+https://", + "git+https://example.com/org/repo?tag=", + "git+https://example.com/org/repo?tag=a&branch=b", + "git+https://example.com/org/repo?tag=a%2Fb", + "git+https://example.com/org/repo?tag=a/b", + "git+https://example.com/org/repo?unknown=a", + "git+https://example.com/org/repo?tag", + "git+https://example.com/org/\"repo", + "git+https://example.com/org\\repo", + "git+https://example.com/org repo", + "git+https://example.com/org/repo#abc", + "git+https://example.com/org/repo\n", + "git+https://example.com/org/%2Frepo", + ] { + assert!( + GitSourceConfig::parse(selector).is_err(), + "should reject selector {selector:?}" + ); + } +} + +#[test] +fn tracked_lockfile_git_packages_all_parse_as_sources() { + let lock = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.lock")) + .expect("should read the tracked Cargo.lock"); + let materials = parse_cargo(&lock).expect("should parse the tracked lockfile"); + assert!( + !materials.git_packages.is_empty(), + "should select a nonzero number of locked Git packages" + ); + for package in &materials.git_packages { + let config = GitSourceConfig::parse(&package.selector) + .expect("should parse every tracked Git source"); + assert!( + config.reference().is_some(), + "should pin {} with a tag, branch or rev", + package.name + ); + assert_eq!(package.revision.len(), 40, "should carry a full revision"); + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs new file mode 100644 index 000000000..23c855137 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs @@ -0,0 +1,187 @@ +//! Fictional originals for vendoring tests; nothing here spawns a process. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use flate2::Compression; +use flate2::write::{GzEncoder, ZlibEncoder}; +use sha2::{Digest as _, Sha256}; + +use crate::git_objects::checked_sha1; +use crate::materials::LockedMaterials; +use crate::materials::git::VerifiedGitMaterial; +use crate::materials::locked::{CargoMaterials, GitPackage, RegistryMaterial}; +use crate::process::Cancellation; + +pub(super) const SOURCE_COMMIT: &str = "1111111111111111111111111111111111111111"; +pub(super) const LOCK_SHA256: &str = + "2222222222222222222222222222222222222222222222222222222222222222"; +pub(super) const SELECTOR: &str = "git+https://example.com/org/workspace?tag=v1"; + +/// A canonical temporary directory. +pub(super) fn temporary() -> tempfile::TempDir { + let root = fs::canonicalize(std::env::temp_dir()).expect("should resolve temporary parent"); + tempfile::tempdir_in(root).expect("should create canonical temporary directory") +} + +pub(super) fn deadline() -> Instant { + Instant::now() + Duration::from_secs(60) +} + +/// Builds a `.crate` archive whose members sit under `-/`. +pub(super) fn crate_archive(name: &str, version: &str, files: &[(&str, &[u8], u32)]) -> Vec { + let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast())); + for (path, bytes, mode) in files { + let mut header = tar::Header::new_ustar(); + header.set_mode(*mode); + header.set_size(bytes.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, format!("{name}-{version}/{path}"), *bytes) + .expect("should append fictional member"); + } + builder + .into_inner() + .expect("should finish fictional TAR") + .finish() + .expect("should finish fictional gzip") +} + +pub(super) fn manifest(name: &str, version: &str) -> Vec { + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n") + .into_bytes() +} + +/// A standard two-file crate. +pub(super) fn plain_crate(name: &str, version: &str) -> Vec { + crate_archive( + name, + version, + &[ + ("Cargo.toml", &manifest(name, version), 0o644), + ("src/lib.rs", b"pub fn fictional() {}\n", 0o644), + ("build.sh", b"#!/bin/sh\n", 0o755), + ], + ) +} + +/// Lock state for `registry` archives and `git` packages. +pub(super) fn locked(registry: &[(&str, &str, &[u8])], git: Vec) -> LockedMaterials { + LockedMaterials { + source_commit: SOURCE_COMMIT.to_owned(), + cargo_lock_sha256: LOCK_SHA256.to_owned(), + npm_lock_sha256: "c".repeat(64), + cargo: CargoMaterials { + registry: registry + .iter() + .map(|(name, version, bytes)| { + ( + ((*name).to_owned(), (*version).to_owned()), + RegistryMaterial { + name: (*name).to_owned(), + version: (*version).to_owned(), + sha256: hex::encode(Sha256::digest(bytes)), + }, + ) + }) + .collect(), + git: git + .iter() + .map(|package| (package.selector.clone(), package.revision.clone())) + .collect(), + git_packages: git, + }, + npm: BTreeMap::new(), + } +} + +/// Writes registry archives into `directory` and returns them by identity. +pub(super) fn archives( + directory: &Path, + entries: &[(&str, &str, &[u8])], +) -> BTreeMap<(String, String), PathBuf> { + entries + .iter() + .map(|(name, version, bytes)| { + let path = directory.join(format!("{name}-{version}.crate")); + fs::write(&path, bytes).expect("should write fictional archive"); + (((*name).to_owned(), (*version).to_owned()), path) + }) + .collect() +} + +pub(super) fn cancellation() -> Cancellation { + Cancellation::default() +} + +/// Builds loose Git objects by hand. +pub(super) struct Objects { + pub(super) store: tempfile::TempDir, +} + +impl Objects { + pub(super) fn new() -> Self { + Self { store: temporary() } + } + + pub(super) fn object(&self, kind: &str, bytes: &[u8]) -> String { + let header = format!("{kind} {}\0", bytes.len()); + let oid = checked_sha1(&[header.as_bytes(), bytes]).expect("should hash fixture object"); + let path = self.store.path().join(&oid[..2]).join(&oid[2..]); + fs::create_dir_all(path.parent().expect("should have parent")) + .expect("should create object directory"); + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(header.as_bytes()) + .expect("should encode header"); + encoder.write_all(bytes).expect("should encode body"); + fs::write(path, encoder.finish().expect("should finish object")) + .expect("should write fixture object"); + oid + } + + pub(super) fn blob(&self, bytes: &[u8]) -> String { + self.object("blob", bytes) + } + + /// Entries are `(mode, name, oid)`; callers list them in Git order. + pub(super) fn tree(&self, entries: &[(&str, &str, &str)]) -> String { + let mut body = Vec::new(); + for (mode, name, oid) in entries { + body.extend_from_slice(format!("{mode} {name}\0").as_bytes()); + body.extend(hex::decode(oid).expect("should decode oid")); + } + self.object("tree", &body) + } + + /// Wraps the store as verified material for `selector` at `revision`. + pub(super) fn material( + self, + selector: &str, + revision: &str, + root_tree: &str, + ) -> VerifiedGitMaterial { + VerifiedGitMaterial::for_tests( + SOURCE_COMMIT, + LOCK_SHA256, + selector, + revision, + root_tree, + self.store, + ) + } +} + +pub(super) const REVISION: &str = "3333333333333333333333333333333333333333"; + +pub(super) fn git_package(name: &str, version: &str) -> GitPackage { + GitPackage { + name: name.to_owned(), + version: version.to_owned(), + selector: SELECTOR.to_owned(), + revision: REVISION.to_owned(), + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/writer.rs b/crates/trusted-server-release/src/materials/cargo_vendor/writer.rs new file mode 100644 index 000000000..435f0fe49 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/writer.rs @@ -0,0 +1,160 @@ +//! Writes one Cargo directory-source package and its `.cargo-checksum.json`. +//! +//! The file format is the one `cargo vendor` writes and Cargo's directory +//! source reads: `{"files":{"":""},"package":}`. +//! Every listed file is rehashed by Cargo when the package is used; `package` +//! must equal the `Cargo.lock` checksum for registry crates and is `null` for +//! Git packages. + +use std::collections::BTreeMap; +use std::fs::{self, DirBuilder, OpenOptions}; +use std::io::Write as _; +use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; +use crate::materials::material_error; + +/// Reserved name Cargo reads the checksums from. +pub(super) const CHECKSUM_FILE: &str = ".cargo-checksum.json"; +/// Longest accepted package-relative path. +const PATH_LIMIT: usize = 4096; + +#[derive(Serialize)] +struct Checksum<'a> { + files: &'a BTreeMap, + package: Option<&'a str>, +} + +/// One package directory under the vendor root, being written. +pub(super) struct PackageWriter { + directory: PathBuf, + files: BTreeMap, +} + +/// Requires a nonempty `/`-separated path of plain components. +fn check_relative(relative: &str) -> Result<(), Report> { + let plain = !relative.is_empty() + && relative.len() <= PATH_LIMIT + && relative.split('/').all(|part| { + !part.is_empty() + && part != "." + && part != ".." + && !part.contains(['\\', '\0', ':']) + && !part.chars().any(char::is_control) + }); + if !plain || relative == CHECKSUM_FILE { + return Err(material_error(format!( + "vendored path {relative:?} is unsafe or reserved" + ))); + } + Ok(()) +} + +impl PackageWriter { + /// Creates `vendor/`, which must not exist yet. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] when the directory exists already + /// (two locked packages with one name and version) or cannot be created. + pub(super) fn create( + vendor: &Path, + directory_name: &str, + ) -> Result> { + let directory = vendor.join(directory_name); + DirBuilder::new() + .mode(0o755) + .create(&directory) + .map_err(|error| { + material_error(format!( + "vendor directory {directory_name} cannot be created exclusively: {error}" + )) + })?; + Ok(Self { + directory, + files: BTreeMap::new(), + }) + } + + /// Writes one file exactly once and records its SHA-256. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] for an unsafe or reserved path, a + /// repeated path, or a failed write. + pub(super) fn write( + &mut self, + relative: &str, + bytes: &[u8], + executable: bool, + ) -> Result<(), Report> { + check_relative(relative)?; + if self.files.contains_key(relative) { + return Err(material_error(format!( + "vendored path {relative} is repeated" + ))); + } + let path = self.directory.join(relative); + if let Some(parent) = path.parent() { + DirBuilder::new() + .recursive(true) + .mode(0o755) + .create(parent) + .map_err(material_error)?; + } + let mode = if executable { 0o755 } else { 0o644 }; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path) + .map_err(material_error)?; + file.write_all(bytes).map_err(material_error)?; + file.set_permissions(fs::Permissions::from_mode(mode)) + .map_err(material_error)?; + file.sync_all().map_err(material_error)?; + self.files + .insert(relative.to_owned(), hex::encode(Sha256::digest(bytes))); + Ok(()) + } + + /// Returns the recorded SHA-256 of a written file. + pub(super) fn digest_of(&self, relative: &str) -> Option<&str> { + self.files.get(relative).map(String::as_str) + } + + /// Writes `.cargo-checksum.json` and returns the written file digests. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] when the file cannot be written. + pub(super) fn finish( + self, + package_checksum: Option<&str>, + ) -> Result, Report> { + let body = serde_json::to_vec(&Checksum { + files: &self.files, + package: package_checksum, + }) + .map_err(material_error)?; + let path = self.directory.join(CHECKSUM_FILE); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path) + .map_err(material_error)?; + file.write_all(&body).map_err(material_error)?; + file.set_permissions(fs::Permissions::from_mode(0o644)) + .map_err(material_error)?; + file.sync_all().map_err(material_error)?; + Ok(self.files) + } +} diff --git a/crates/trusted-server-release/src/materials/extract.rs b/crates/trusted-server-release/src/materials/extract.rs new file mode 100644 index 000000000..debbfc957 --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract.rs @@ -0,0 +1,611 @@ +//! Extracts authenticated originals into fresh private state, without execution. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io::{self, BufReader, Read, Seek as _, Write as _}; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use flate2::bufread::GzDecoder; +use sha2::{Digest as _, Sha256}; +use tempfile::TempDir; + +use super::{VerifiedOriginal, material_error}; +use crate::documents::{Role, hash_bounded_with_check, read_bounded}; +use crate::error::ReleaseError; + +#[derive(Clone, Copy)] +pub(super) struct Selection<'a> { + pub(super) root: &'a str, + pub(super) index_alias: bool, + pub(super) node_tar_timestamps: bool, + pub(super) local_pax: bool, + /// Accepts the two encodings `cargo package` itself writes into `.crate` + /// archives: a GNU long-name record before a member whose path exceeds + /// the 100-byte header field, and (in older Cargo releases) the regular + /// file or directory type bits inside the mode field. + pub(super) cargo_package: bool, +} + +mod compatibility; + +const DECODED_LIMIT: u64 = 512 * 1024 * 1024; +// The approved npm lock includes a 110,275,248-byte optional native member. +// Keep the compressed-original and total decoded limits independent. +const MEMBER_LIMIT: u64 = 128 * 1024 * 1024; +const ENTRY_LIMIT: usize = 100_000; +const DEPTH_LIMIT: usize = 64; +const PATH_LIMIT: usize = 4096; + +#[derive(PartialEq, Eq)] +struct MemberFacts { + size: u64, + mode: u32, + sha256: String, +} + +/// A privately extracted original, without installation or execution authority. +pub(crate) struct ExtractedOriginal { + original: VerifiedOriginal, + directory: TempDir, + root: PathBuf, + files: BTreeMap, + root_name: String, + directories: BTreeSet, +} + +impl ExtractedOriginal { + /// Returns a rechecked regular member's original digest, length and mode. + /// + /// # Errors + /// + /// Returns an error if the tree changed or the member was not recorded. + pub(super) fn member_facts( + &self, + relative: &str, + ) -> Result<(String, u64, u32), Report> { + self.verify_unchanged()?; + let facts = self + .files + .get(&format!("{}/{relative}", self.root_name)) + .ok_or_else(|| { + material_error("required native tool is not an authenticated archive member") + })?; + Ok((facts.sha256.clone(), facts.size, facts.mode)) + } + /// Rejoins the entire private extraction before it is consumed again. + /// + /// # Errors + /// + /// Returns an error for a changed, missing or added member, unsafe type, + /// changed executable mode, or an expired attempt. + pub(crate) fn verify_unchanged(&self) -> Result<(), Report> { + self.original.check()?; + let mut remaining: BTreeSet<_> = self.files.keys().cloned().collect(); + let mut remaining_directories = self.directories.clone(); + let mut pending = vec![self.root_name.clone()]; + while let Some(directory) = pending.pop() { + self.original.check()?; + let metadata = fs::symlink_metadata(self.directory.path().join(&directory)) + .map_err(material_error)?; + if !metadata.is_dir() { + return Err(material_error( + "extracted directory changed type or became an alias", + )); + } + remaining_directories.remove(&directory); + let entries = + fs::read_dir(self.directory.path().join(&directory)).map_err(material_error)?; + for entry in entries { + self.original.check()?; + let entry = entry.map_err(material_error)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| material_error("extracted member is not UTF-8"))?; + let key = format!("{directory}/{name}"); + let metadata = fs::symlink_metadata(entry.path()).map_err(material_error)?; + if metadata.is_dir() { + if !self.directories.contains(&key) { + return Err(material_error("unrecorded extracted directory appeared")); + } + pending.push(key); + } else { + let expected = self + .files + .get(&key) + .ok_or_else(|| material_error("unrecorded extracted member appeared"))?; + if !metadata.is_file() + || metadata.len() != expected.size + || metadata.permissions().mode() & 0o7777 != 0o600 | (expected.mode & 0o111) + { + return Err(material_error( + "extracted member type, size or permissions changed", + )); + } + let raw = hash_bounded_with_check(&entry.path(), MEMBER_LIMIT, &|| { + self.original.check() + })?; + if raw.sha256 != expected.sha256 + || raw.size != expected.size + || raw.mode & 0o7777 != 0o600 | (expected.mode & 0o111) + { + return Err(material_error("extracted member bytes changed")); + } + remaining.remove(&key); + } + } + } + if !remaining.is_empty() || !remaining_directories.is_empty() { + return Err(material_error("recorded extracted member is missing")); + } + self.original.check() + } + + /// Copies only the retained authenticated compressed original. + /// + /// # Errors + /// + /// Returns an error if the retained original changed or the copy fails. + pub(super) fn copy_original( + &mut self, + destination: &mut impl io::Write, + ) -> Result<(), Report> { + self.original.copy_verified(destination) + } + /// Returns the locked acquisition identity supplying this extracted tree. + pub(super) fn identity(&self) -> &str { + self.original.identity() + } + + /// Reads a bounded manifest input and rejoins its extraction-time digest. + /// + /// # Errors + /// + /// Returns an error for an unrecorded, changed, linked or oversized input, + /// or when the original's deadline/cancellation boundary has expired. + pub(super) fn read_member_checked( + &self, + relative: &str, + ) -> Result, Report> { + self.original.check()?; + let key = format!("{}/{relative}", self.root_name); + let expected = self + .files + .get(&key) + .ok_or_else(|| material_error("manifest input is not a recorded archive member"))?; + let raw = read_bounded(&self.root.join(relative), Role::Metadata)?; + if raw.sha256 != expected.sha256 || raw.bytes.len() as u64 != expected.size { + return Err(material_error("extracted manifest input changed")); + } + self.original.check()?; + Ok(raw.bytes) + } + + /// Checks recorded manifest-input presence without allowing a later omission. + /// + /// # Errors + /// + /// Returns an error if the selected original input was removed or changed, + /// an unrecorded input appeared, or the attempt expired. + pub(super) fn contains_member_checked( + &self, + relative: &str, + ) -> Result> { + self.original.check()?; + let key = format!("{}/{relative}", self.root_name); + if self.files.contains_key(&key) { + self.read_member_checked(relative)?; + return Ok(true); + } + match fs::symlink_metadata(self.root.join(relative)) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + _ => Err(material_error( + "unrecorded or invalid manifest input appeared", + )), + } + } + /// Returns the privately owned package root for later preparation checks. + pub(crate) fn root(&self) -> &Path { + &self.root + } + + /// Returns the approved source identity of the retained original. + pub(crate) fn source_commit(&self) -> &str { + self.original.source_commit() + } +} + +/// Checks cancellation and decoded expansion on every bounded read. +struct Decoded<'a, R> { + reader: R, + original: &'a VerifiedOriginal, + count: u64, +} + +impl Read for Decoded<'_, R> { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + self.original + .check() + .map_err(|_| io::Error::other("archive cancelled or timed out"))?; + let maximum = bytes.len().min(64 * 1024); + let count = self.reader.read(&mut bytes[..maximum])?; + self.count += count as u64; + if self.count > DECODED_LIMIT { + return Err(io::Error::other("archive exceeds decoded byte limit")); + } + Ok(count) + } +} + +fn 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(material_error( + "TAR name has hidden bytes after its terminator", + )); + } + std::str::from_utf8(&bytes[..end]).map_err(material_error) +} + +/// Uses raw fields because [`tar::Entry::path_bytes`] normalizes backslashes. +fn raw_name( + header: &tar::Header, + selection: &Selection<'_>, +) -> Result> { + let name = field(&header.as_old().name)?; + match header.as_ustar() { + Some(ustar) => { + let prefix = + if selection.node_tar_timestamps && ustar.prefix.iter().any(|byte| *byte != 0) { + compatibility::timestamp_prefix(header)? + } else { + field(&ustar.prefix)? + }; + Ok(if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}/{name}") + }) + } + None => Ok(name.to_owned()), + } +} + +fn member_name( + raw: &str, + directory: bool, + selection: &Selection<'_>, +) -> Result> { + if raw.is_empty() + || raw.len() > PATH_LIMIT + || !raw.is_ascii() + || raw + .bytes() + .any(|byte| byte.is_ascii_control() || matches!(byte, b'\\' | b':')) + { + return Err(material_error("TAR path is not a bounded portable name")); + } + let name = if directory { + raw.strip_suffix('/').unwrap_or(raw) + } else { + raw + }; + let name = if !directory && selection.index_alias && name == "package/./dist/index.js" { + "package/dist/index.js" + } else { + name + }; + let parts: Vec<&str> = name.split('/').collect(); + if parts.len() > DEPTH_LIMIT + || parts + .iter() + .any(|part| part.is_empty() || *part == "." || *part == "..") + || parts[0] != selection.root + || (!directory && parts.len() == 1) + { + return Err(material_error("TAR path leaves its exact locked root")); + } + Ok(name.to_owned()) +} + +struct State { + directory: TempDir, + spellings: BTreeSet, + portable: BTreeMap, + files: BTreeMap, + first_spelling: BTreeMap, + directories: BTreeSet, +} + +impl State { + fn portable_name(&mut self, name: &str) -> Result<(), Report> { + let mut prefix = String::new(); + for part in name.split('/') { + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(part); + let lower = prefix.to_ascii_lowercase(); + if let Some(previous) = self.portable.insert(lower, prefix.clone()) + && previous != prefix + { + return Err(material_error("TAR has case-aliased paths")); + } + } + Ok(()) + } + + fn entry( + &mut self, + mut entry: tar::Entry<'_, R>, + selection: &Selection<'_>, + long_name: Option, + ) -> Result<(), Report> { + let header = entry.header(); + let directory = header.entry_type().is_dir(); + if !directory && !header.entry_type().is_file() { + return Err(material_error( + "TAR member is not a regular file or directory", + )); + } + let raw = match long_name { + Some(long) => compatibility::join_long_name(long, &raw_name(header, selection)?)?, + None => raw_name(header, selection)?, + }; + let name = member_name(&raw, directory, selection)?; + if !self.spellings.insert(raw.clone()) { + return Err(material_error("TAR repeats a member spelling")); + } + self.portable_name(&name)?; + let mode = header.mode().map_err(material_error)?; + let mode = if selection.cargo_package { + compatibility::strip_member_type(mode, directory)? + } else { + mode + }; + let size = entry.size(); + if mode & !0o777 != 0 || (directory && size != 0) { + return Err(material_error(format!( + "TAR member `{name}` has privileged mode {mode:o} or invalid size {size}", + ))); + } + if size > MEMBER_LIMIT { + return Err(material_error(format!( + "TAR member `{name}` size {size} exceeds member byte limit {MEMBER_LIMIT}", + ))); + } + let path = self.directory.path().join(&name); + if directory { + if !self.directories.insert(name) { + return Err(material_error("TAR repeats a directory path")); + } + fs::create_dir_all(&path).map_err(material_error)?; + return Ok(()); + } + let duplicate = self.files.contains_key(&name); + if duplicate + && !(selection.index_alias + && name == "package/dist/index.js" + && matches!( + ( + self.first_spelling.get(&name).map(String::as_str), + raw.as_str() + ), + (Some("package/./dist/index.js"), "package/dist/index.js") + | (Some("package/dist/index.js"), "package/./dist/index.js") + )) + { + return Err(material_error("TAR has an unreviewed duplicate path")); + } + let mut file = if duplicate { + None + } else { + fs::create_dir_all( + path.parent() + .ok_or_else(|| material_error("member has no parent"))?, + ) + .map_err(material_error)?; + Some( + File::options() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .map_err(material_error)?, + ) + }; + let mut hasher = Sha256::new(); + let mut count = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = entry.read(&mut buffer).map_err(material_error)?; + if read == 0 { + break; + } + count += read as u64; + if count > size { + return Err(material_error("TAR member exceeds declared size")); + } + hasher.update(&buffer[..read]); + if let Some(file) = &mut file { + file.write_all(&buffer[..read]).map_err(material_error)?; + } + } + if count != size { + return Err(material_error("TAR member is truncated")); + } + let facts = MemberFacts { + size, + mode, + sha256: hex::encode(hasher.finalize()), + }; + if duplicate { + if self.files.get(&name) != Some(&facts) { + return Err(material_error("reviewed TAR aliases differ")); + } + } else { + // Only executable bits carry across; ownership and ambient write access do not. + file.as_ref() + .expect("should have exclusively created member") + .set_permissions(fs::Permissions::from_mode(0o600 | (mode & 0o111))) + .map_err(material_error)?; + self.first_spelling.insert(name.clone(), raw); + self.files.insert(name, facts); + } + Ok(()) + } +} + +pub(super) fn extract( + original: VerifiedOriginal, + selection: Selection<'_>, +) -> Result> { + extract_bounded(original, selection, ENTRY_LIMIT) +} + +fn extract_bounded( + mut original: VerifiedOriginal, + selection: Selection<'_>, + entry_limit: usize, +) -> Result> { + let mut compressed = tempfile::tempfile().map_err(material_error)?; + original.copy_verified(&mut compressed)?; + compressed.rewind().map_err(material_error)?; + let decoder = GzDecoder::new(BufReader::new(compressed)); + let decoded = Decoded { + reader: decoder, + original: &original, + count: 0, + }; + let mut archive = tar::Archive::new(decoded); + let mut state = State { + directory: tempfile::tempdir().map_err(material_error)?, + spellings: BTreeSet::new(), + portable: BTreeMap::new(), + files: BTreeMap::new(), + first_spelling: BTreeMap::new(), + directories: BTreeSet::new(), + }; + let mut pending_pax: Option = None; + let mut pending_long: Option = None; + for (index, entry) in archive + .entries() + .map_err(material_error)? + .raw(true) + .enumerate() + { + if index >= entry_limit { + return Err(material_error("TAR exceeds entry count limit")); + } + let mut entry = entry.map_err(material_error)?; + if entry.header().entry_type().is_gnu_longname() { + if !selection.cargo_package || pending_long.is_some() || pending_pax.is_some() { + return Err(material_error( + "unreviewed, consecutive or mixed GNU long-name record", + )); + } + pending_long = Some(compatibility::read_long_name(&mut entry, PATH_LIMIT)?); + continue; + } + if entry.header().entry_type().as_byte() == b'x' { + if pending_long.is_some() { + return Err(material_error( + "GNU long-name record must immediately precede its member", + )); + } + if !selection.local_pax || pending_pax.is_some() || entry.size() > 64 * 1024 { + return Err(material_error( + "unreviewed, consecutive or oversized local PAX header", + )); + } + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).map_err(material_error)?; + let pax = compatibility::parse_pax(&bytes)?; + if raw_name(entry.header(), &selection)? != pax.header_name() + || entry.header().mode().map_err(material_error)? & !0o777 != 0 + || !field(&entry.header().as_old().linkname)?.is_empty() + { + return Err(material_error("unsafe reviewed local PAX header")); + } + pending_pax = Some(pax); + continue; + } + if let Some(pax) = pending_pax.take() { + if !entry.header().entry_type().is_file() { + return Err(material_error( + "local PAX must immediately precede a regular file", + )); + } + pax.join( + &raw_name(entry.header(), &selection)?, + entry.header().size().map_err(material_error)?, + )?; + } + state.entry(entry, &selection, pending_long.take())?; + } + if pending_pax.is_some() { + return Err(material_error("dangling local PAX header")); + } + if pending_long.is_some() { + return Err(material_error("dangling GNU long-name record")); + } + if state.spellings.contains("package/./dist/index.js") + && !state.spellings.contains("package/dist/index.js") + { + return Err(material_error( + "reviewed dot alias lacks its canonical partner", + )); + } + // The parser consumed the first zero header. Require at least the second, + // then only complete zero blocks through the checked gzip trailer. + 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(material_error)?; + if count == 0 { + break; + } + tail += count as u64; + if buffer[..count].iter().any(|byte| *byte != 0) { + return Err(material_error("TAR hides data after its terminator")); + } + } + if tail < 512 || !tail.is_multiple_of(512) { + return Err(material_error("TAR lacks complete terminator blocks")); + } + let mut compressed = decoded.reader.into_inner(); + if compressed.read(&mut buffer[..1]).map_err(material_error)? != 0 { + return Err(material_error( + "gzip has additional members or trailing bytes", + )); + } + original.check()?; + let root = state.directory.path().join(selection.root); + if !root.is_dir() || state.files.is_empty() { + return Err(material_error("archive has no package files")); + } + for file in state.files.keys() { + let mut current = file.as_str(); + while let Some((parent, _)) = current.rsplit_once('/') { + state.directories.insert(parent.to_owned()); + current = parent; + } + } + Ok(ExtractedOriginal { + root_name: selection.root.to_owned(), + original, + root, + directory: state.directory, + files: state.files, + directories: state.directories, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/materials/extract/compatibility.rs b/crates/trusted-server-release/src/materials/extract/compatibility.rs new file mode 100644 index 000000000..f4dc546d0 --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract/compatibility.rs @@ -0,0 +1,219 @@ +//! Closed compatibility for authenticated, exact-version npm originals and +//! for the encodings `cargo package` writes into registry crates. +use super::{field, material_error}; +use crate::error::ReleaseError; +use error_stack::Report; +use std::collections::BTreeMap; + +pub(super) fn timestamp_prefix(header: &tar::Header) -> Result<&str, Report> { + let bytes = header.as_bytes(); + if &bytes[257..265] != b"ustar\x0000" + || bytes[345..476].iter().any(|byte| *byte != 0) + || bytes[500..].iter().any(|byte| *byte != 0) + || [476, 488].iter().any(|start| { + bytes[*start..*start + 11] + .iter() + .any(|byte| !matches!(byte, b'0'..=b'7')) + || bytes[*start + 11] != 0 + }) + { + return Err(material_error("invalid reviewed node-tar timestamp layout")); + } + field(&bytes[345..475]) +} + +pub(super) struct LocalPax { + path: String, + size: u64, +} +impl LocalPax { + pub(super) fn header_name(&self) -> String { + format!("PaxHeader/{}", self.path) + } + pub(super) fn join(&self, path: &str, size: u64) -> Result<(), Report> { + if self.path != path || self.size != size { + return Err(material_error( + "local PAX path or size would override raw member", + )); + } + Ok(()) + } +} +const KEYS: &[&str] = &[ + "NODETAR.blksize", + "NODETAR.blocks", + "NODETAR.depth", + "NODETAR.follow", + "NODETAR.ignoreFiles.0", + "NODETAR.ignoreFiles.1", + "NODETAR.ignoreFiles.2", + "NODETAR.package.author.email", + "NODETAR.package.author.name", + "NODETAR.package.author.url", + "NODETAR.package.contributors.0.email", + "NODETAR.package.contributors.0.name", + "NODETAR.package.contributors.0.url", + "NODETAR.package.dependencies.consolidate", + "NODETAR.package.dependencies.es6-promise", + "NODETAR.package.dependencies.fs-readfile-promise", + "NODETAR.package.dependencies.js-yaml", + "NODETAR.package.dependencies.lodash", + "NODETAR.package.dependencies.node.extend", + "NODETAR.package.dependencies.plugin-error", + "NODETAR.package.dependencies.through2", + "NODETAR.package.dependencies.tryit", + "NODETAR.package.dependencies.vinyl-bufferstream", + "NODETAR.package.description", + "NODETAR.package.devDependencies.eslint", + "NODETAR.package.devDependencies.expect.js", + "NODETAR.package.devDependencies.istanbul", + "NODETAR.package.devDependencies.istanbul-coveralls", + "NODETAR.package.devDependencies.jscs", + "NODETAR.package.devDependencies.mocha", + "NODETAR.package.devDependencies.simple-bufferstream", + "NODETAR.package.devDependencies.vinyl", + "NODETAR.package.engines.node", + "NODETAR.package.engines.npm", + "NODETAR.package.files.0", + "NODETAR.package.jscsConfig.maximumLineLength", + "NODETAR.package.jscsConfig.preset", + "NODETAR.package.jscsConfig.requireBlocksOnNewline", + "NODETAR.package.jscsConfig.validateLineBreaks", + "NODETAR.package.keywords.0", + "NODETAR.package.keywords.1", + "NODETAR.package.keywords.2", + "NODETAR.package.keywords.3", + "NODETAR.package.keywords.4", + "NODETAR.package.keywords.5", + "NODETAR.package.licenses.0.type", + "NODETAR.package.licenses.0.url", + "NODETAR.package.name", + "NODETAR.package.repository", + "NODETAR.package.scripts.coverage", + "NODETAR.package.scripts.coveralls", + "NODETAR.package.scripts.pretest", + "NODETAR.package.scripts.test", + "NODETAR.package.version", + "NODETAR.type", + "SCHILY.dev", + "SCHILY.ino", + "SCHILY.nlink", + "gid", + "path", + "size", + "uid", +]; +pub(super) fn parse_pax(mut bytes: &[u8]) -> Result> { + let mut values = BTreeMap::new(); + while !bytes.is_empty() { + let space = bytes + .iter() + .position(|byte| *byte == b' ') + .ok_or_else(|| material_error("invalid PAX length framing"))?; + let digits = std::str::from_utf8(&bytes[..space]).map_err(material_error)?; + if digits.is_empty() + || digits.starts_with('0') + || !digits.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(material_error("noncanonical PAX length")); + } + let length = digits.parse::().map_err(material_error)?; + if length <= space + 2 || length > bytes.len() || bytes[length - 1] != b'\n' { + return Err(material_error("invalid PAX record length")); + } + let record = std::str::from_utf8(&bytes[space + 1..length - 1]).map_err(material_error)?; + let (key, value) = record + .split_once('=') + .ok_or_else(|| material_error("invalid PAX assignment"))?; + if !KEYS.contains(&key) + || value.contains(['\0', '\n', '\r']) + || values.insert(key, value).is_some() + { + return Err(material_error("unknown or repeated PAX metadata field")); + } + bytes = &bytes[length..]; + } + let path = values + .get("path") + .ok_or_else(|| material_error("PAX lacks path"))? + .to_string(); + let size = values + .get("size") + .ok_or_else(|| material_error("PAX lacks size"))?; + if size.is_empty() + || (size.len() > 1 && size.starts_with('0')) + || !size.bytes().all(|byte| byte.is_ascii_digit()) + || values.get("NODETAR.type") != Some(&"File") + { + return Err(material_error("invalid PAX size or member type")); + } + Ok(LocalPax { + path, + size: size.parse().map_err(material_error)?, + }) +} + +/// Name field width of a TAR header. +const HEADER_NAME_WIDTH: usize = 100; +/// Name `cargo package` gives every GNU long-name record. +const LONG_NAME_RECORD: &str = "././@LongLink"; +/// File-type bits of a mode field. +const TYPE_BITS: u32 = 0o170_000; + +/// Reads one GNU long-name record as `cargo package` writes it: named +/// `././@LongLink`, with a plain regular-file mode and no link target, holding +/// at most `limit` path bytes followed by exactly one NUL. +pub(super) fn read_long_name( + entry: &mut tar::Entry<'_, R>, + limit: usize, +) -> Result> { + let header = entry.header(); + let mode = strip_member_type(header.mode().map_err(material_error)?, false)?; + if field(&header.as_old().name)? != LONG_NAME_RECORD + || !field(&header.as_old().linkname)?.is_empty() + || mode & !0o777 != 0 + { + return Err(material_error("unreviewed GNU long-name record header")); + } + let size = entry.size(); + if size < 2 || size > limit as u64 + 1 { + return Err(material_error("GNU long-name record has an invalid size")); + } + let mut bytes = Vec::new(); + std::io::Read::read_to_end(entry, &mut bytes).map_err(material_error)?; + let name = bytes + .strip_suffix(b"\0") + .filter(|name| bytes.len() as u64 == size && !name.contains(&0)) + .ok_or_else(|| material_error("GNU long name is not one NUL-terminated path"))?; + String::from_utf8(name.to_vec()).map_err(material_error) +} + +/// Joins a long name with the member header it describes. +/// +/// The header must hold exactly the first 100 bytes of the long name, so a +/// record can only lengthen the member it precedes, never rename it. +pub(super) fn join_long_name( + long: String, + header_name: &str, +) -> Result> { + let consistent = long.len() > HEADER_NAME_WIDTH + && header_name.len() == HEADER_NAME_WIDTH + && long.as_bytes().starts_with(header_name.as_bytes()); + if !consistent { + return Err(material_error( + "GNU long name does not extend its member's header name", + )); + } + Ok(long) +} + +/// Removes the regular-file or directory type bits older `cargo package` +/// releases left in the mode field; any other type is rejected. +pub(super) fn strip_member_type(mode: u32, directory: bool) -> Result> { + let expected = if directory { 0o040_000 } else { 0o100_000 }; + match mode & TYPE_BITS { + 0 => Ok(mode), + kind if kind == expected => Ok(mode & !TYPE_BITS), + _ => Err(material_error("TAR member mode names another file type")), + } +} diff --git a/crates/trusted-server-release/src/materials/extract/tests.rs b/crates/trusted-server-release/src/materials/extract/tests.rs new file mode 100644 index 000000000..15a4db838 --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract/tests.rs @@ -0,0 +1,667 @@ +use std::fs; +use std::io; +use std::time::{Duration, Instant}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use flate2::{Compression, write::GzEncoder}; +use serde_json::json; +use sha2::{Sha256, Sha512}; + +use super::*; +use crate::materials::archive::{Expected, verify}; +use crate::materials::{LockedMaterials, locked}; +use crate::process::Cancellation; + +fn compressed(tar: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(tar).expect("should compress TAR bytes"); + encoder.finish().expect("should finish gzip trailer") +} + +fn tar_bytes(entries: &[(&str, u8, u32, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + for (name, kind, mode, bytes) in entries { + let mut header = tar::Header::new_ustar(); + header.set_entry_type(tar::EntryType::new(*kind)); + header.set_mode(*mode); + header.set_size(bytes.len() as u64); + if name.len() <= 100 { + header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes()); + } else { + let (prefix, leaf) = name + .rsplit_once('/') + .expect("should split long fixture name"); + let ustar = header.as_ustar_mut().expect("should have USTAR header"); + ustar.prefix[..prefix.len()].copy_from_slice(prefix.as_bytes()); + ustar.name[..leaf.len()].copy_from_slice(leaf.as_bytes()); + } + header.set_cksum(); + builder + .append(&header, *bytes) + .expect("should append fixture entry"); + } + builder.into_inner().expect("should finish TAR") +} + +fn original(bytes: &[u8]) -> VerifiedOriginal { + original_with_timeout(bytes, Duration::from_secs(10)) +} + +fn original_with_timeout(bytes: &[u8], timeout: Duration) -> VerifiedOriginal { + let directory = tempfile::tempdir().expect("should create archive directory"); + let path = directory.path().join("original.tgz"); + fs::write(&path, bytes).expect("should write archive"); + verify( + &path, + Expected::Sha256(&hex::encode(Sha256::digest(bytes))), + &"a".repeat(40), + "npm:node_modules/fictional-package", + Instant::now() + timeout, + &Cancellation::default(), + ) + .expect("should verify original bytes") +} + +#[test] +fn extracts_verified_regular_files_and_preserves_original_bytes() { + let bytes = compressed(&tar_bytes(&[( + "package/bin/tool", + b'0', + 0o755, + b"fictional executable", + )])); + let extracted = extract( + original(&bytes), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + }, + ) + .expect("should extract verified regular file"); + assert_eq!( + fs::read(extracted.directory.path().join("package/bin/tool")).expect("should read file"), + b"fictional executable", + "should retain exact member bytes" + ); + let mut retained = Vec::new(); + let mut original = extracted.original; + original + .copy_verified(&mut retained) + .expect("should retain authenticated compressed original"); + assert_eq!(retained, bytes, "should preserve exact compressed bytes"); +} + +#[test] +fn accepts_bounded_native_members_above_64_mib() { + let size = 64 * 1024 * 1024 + 1; + let encoder = GzEncoder::new(Vec::new(), Compression::fast()); + let mut builder = tar::Builder::new(encoder); + let mut header = tar::Header::new_ustar(); + header.set_mode(0o644); + header.set_size(size); + header.set_cksum(); + builder + .append_data(&mut header, "package/native.node", io::repeat(0).take(size)) + .expect("should stream a bounded large native member"); + let bytes = builder + .into_inner() + .expect("should finish native fixture TAR") + .finish() + .expect("should finish native fixture gzip"); + let extracted = extract( + original_with_timeout(&bytes, Duration::from_secs(60)), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + }, + ) + .expect("should support authenticated native members larger than 64 MiB"); + assert_eq!( + fs::metadata(extracted.root().join("native.node")) + .expect("should inspect native member") + .len(), + size, + "should retain every original native byte" + ); + extracted + .verify_unchanged() + .expect("should stream recheck the entire large native member"); +} + +#[test] +fn rejects_unsafe_paths_types_modes_duplicates_and_collisions() { + for entries in [ + vec![("other/file", b'0', 0o644, b"x".as_slice())], + vec![("package/../file", b'0', 0o644, b"x".as_slice())], + vec![("package/./file", b'0', 0o644, b"x".as_slice())], + vec![("package\\file", b'0', 0o644, b"x".as_slice())], + vec![("/package/file", b'0', 0o644, b"x".as_slice())], + vec![("package/file", b'0', 0o4644, b"x".as_slice())], + vec![("package/file", b'2', 0o644, b"".as_slice())], + vec![("package/file", b'1', 0o644, b"".as_slice())], + vec![("package/file", b'6', 0o644, b"".as_slice())], + vec![("package/file", b'x', 0o644, b"x".as_slice())], + vec![ + ("package/file", b'0', 0o644, b"x".as_slice()), + ("package/file", b'0', 0o644, b"x".as_slice()), + ], + vec![ + ("package/file", b'0', 0o644, b"x".as_slice()), + ("package/file/child", b'0', 0o644, b"x".as_slice()), + ], + vec![ + ("package/File", b'0', 0o644, b"x".as_slice()), + ("package/file", b'0', 0o644, b"x".as_slice()), + ], + vec![ + ("package/", b'5', 0o755, b"".as_slice()), + ("package", b'5', 0o755, b"".as_slice()), + ("package/file", b'0', 0o644, b"x".as_slice()), + ], + ] { + assert!( + extract( + original(&compressed(&tar_bytes(&entries))), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_err(), + "should reject unsafe member set" + ); + } +} + +#[test] +fn rejects_excessive_depth_member_size_and_cancelled_extraction() { + let name = format!("package/{}file", "a/".repeat(64)); + let bytes = compressed(&tar_bytes(&[(&name, b'0', 0o644, b"x")])); + assert!( + extract( + original(&bytes), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_err(), + "should reject excessive member depth" + ); + let mut tar = tar_bytes(&[("package/file", b'0', 0o644, b"x")]); + let mut header = tar::Header::new_ustar(); + header.as_mut_bytes().copy_from_slice(&tar[..512]); + header.set_size(MEMBER_LIMIT + 1); + header.set_cksum(); + tar[..512].copy_from_slice(header.as_bytes()); + let rejected = extract( + original(&compressed(&tar)), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + }, + ) + .err() + .expect("should reject oversized member before reading body"); + let diagnostic = format!("{rejected:?}"); + assert!( + diagnostic.contains("package/file") + && diagnostic.contains("134217729") + && diagnostic.contains("134217728"), + "should identify the rejected member, actual size and enforced bound" + ); + let bytes = compressed(&tar_bytes(&[("package/file", b'0', 0o644, b"x")])); + let cancellation = Cancellation::default(); + // Reverification binds the extraction to the same cancellation token. + let directory = tempfile::tempdir().expect("should create archive directory"); + let path = directory.path().join("original.tgz"); + fs::write(&path, &bytes).expect("should write archive"); + let original = verify( + &path, + Expected::Sha256(&hex::encode(Sha256::digest(&bytes))), + &"a".repeat(40), + "npm:node_modules/fictional-package", + Instant::now() + Duration::from_secs(10), + &cancellation, + ) + .expect("should verify original"); + cancellation.cancel(); + assert!( + extract( + original, + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_err(), + "should carry cancellation into extraction" + ); +} + +#[test] +fn index_alias_exception_requires_identical_pair_and_no_third_entry() { + let pair = [ + ("package/./dist/index.js", b'0', 0o644, b"same".as_slice()), + ("package/dist/index.js", b'0', 0o644, b"same".as_slice()), + ]; + let bytes = compressed(&tar_bytes(&pair)); + assert!( + extract( + original(&compressed(&tar_bytes(&pair[..1]))), + Selection { + root: "package", + index_alias: true, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_err(), + "should require canonical partner for dot alias" + ); + assert!( + extract( + original(&bytes), + Selection { + root: "package", + index_alias: true, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_ok(), + "should allow exactly reviewed identical pair" + ); + assert!( + extract( + original(&bytes), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_err(), + "should reject pair for other locked identities" + ); + let mut changed = pair; + changed[1].3 = b"evil"; + assert!( + extract( + original(&compressed(&tar_bytes(&changed))), + Selection { + root: "package", + index_alias: true, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_err(), + "should reject different alias bytes" + ); + let mut third = pair.to_vec(); + third.push(pair[0]); + assert!( + extract( + original(&compressed(&tar_bytes(&third))), + Selection { + root: "package", + index_alias: true, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_err(), + "should reject third occurrence" + ); + let directory_alias = [ + ("package/./dist/index.js/", b'5', 0o755, b"".as_slice()), + ("package/file", b'0', 0o644, b"x".as_slice()), + ]; + assert!( + extract( + original(&compressed(&tar_bytes(&directory_alias))), + Selection { + root: "package", + index_alias: true, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_err(), + "should restrict dot alias exception to regular files" + ); +} + +#[test] +fn rejects_hidden_data_bad_trailers_concatenation_and_truncation() { + let tar = tar_bytes(&[("package/file", b'0', 0o644, b"x")]); + let mut hidden = tar.clone(); + hidden.extend_from_slice(&tar); + let bytes = compressed(&tar); + let mut corrupt = bytes.clone(); + let last = corrupt.len() - 8; + corrupt[last] ^= 1; + let mut concatenated = bytes.clone(); + concatenated.extend_from_slice(&bytes); + let mut trailing = bytes.clone(); + trailing.push(0); + for bad in [ + compressed(&hidden), + corrupt, + concatenated, + trailing, + bytes[..bytes.len() - 1].to_vec(), + compressed(&tar[..512]), + ] { + assert!( + extract( + original(&bad), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + } + ) + .is_err(), + "should reject incomplete or ambiguous archive" + ); + } +} + +fn catalog(bytes: &[u8]) -> LockedMaterials { + let lock = serde_json::to_vec(&json!({"lockfileVersion":3, "packages": { + "": {}, "node_modules/fictional-package": { + "version":"1.0.0", "resolved":format!("{}fictional-package/-/fictional-package-1.0.0.tgz", locked::NPM_REGISTRY), + "integrity":format!("sha512-{}", STANDARD.encode(Sha512::digest(bytes))) + } + }})).expect("should encode locked original identity"); + LockedMaterials { + source_commit: "a".repeat(40), + cargo_lock_sha256: "0".repeat(64), + npm_lock_sha256: "0".repeat(64), + cargo: locked::parse_cargo(b"version = 4\npackage = []\n") + .expect("should parse empty Cargo lock"), + npm: locked::parse_npm(&lock).expect("should parse fixture npm lock"), + } +} + +#[test] +fn catalog_extraction_rejoins_source_and_locked_acquisition_identity() { + let bytes = compressed(&tar_bytes(&[("package/file", b'0', 0o644, b"x")])); + let mut catalog = catalog(&bytes); + assert!( + catalog + .extract_npm("node_modules/fictional-package", original(&bytes)) + .is_ok(), + "should extract bound locked original" + ); + assert!( + catalog + .extract_npm("node_modules/other", original(&bytes)) + .is_err(), + "should reject unknown installation identity" + ); + assert!( + catalog + .extract_registry("fictional-package", "1.0.0", original(&bytes)) + .is_err(), + "should reject npm original as a crate" + ); + catalog.source_commit = "b".repeat(40); + assert!( + catalog + .extract_npm("node_modules/fictional-package", original(&bytes)) + .is_err(), + "should reject original bound to different approved source" + ); +} + +#[test] +fn manifest_review_uses_original_members_and_rejects_later_changes() { + let manifest = serde_json::to_vec(&json!({"name":"fictional-package","version":"1.0.0"})) + .expect("should encode fixture manifest"); + for binding in [false, true] { + let mut entries = vec![("package/package.json", b'0', 0o644, manifest.as_slice())]; + if binding { + entries.push(( + "package/binding.gyp", + b'0', + 0o644, + b"fictional binding".as_slice(), + )); + } + let bytes = compressed(&tar_bytes(&entries)); + let catalog = catalog(&bytes); + let extract_package = || { + catalog + .extract_npm("node_modules/fictional-package", original(&bytes)) + .expect("should extract original manifest") + }; + let reviewed = catalog.review_npm("node_modules/fictional-package", extract_package()); + if binding { + assert!(reviewed.is_err(), "should reject original implicit install"); + let extracted = extract_package(); + fs::remove_file(extracted.root().join("binding.gyp")) + .expect("should remove fixture binding"); + assert!( + catalog + .review_npm("node_modules/fictional-package", extracted) + .is_err(), + "should reject removed original binding" + ); + } else { + assert!( + reviewed + .expect("should review hook-free original") + .hooks() + .is_empty(), + "should declare no execution" + ); + let extracted = extract_package(); + fs::write(extracted.root().join("binding.gyp"), b"added binding") + .expect("should add fixture binding"); + assert!( + catalog + .review_npm("node_modules/fictional-package", extracted) + .is_err(), + "should reject unrecorded binding" + ); + } + let extracted = extract_package(); + fs::write(extracted.root().join("package.json"), b"{}").expect("should change manifest"); + assert!( + catalog + .review_npm("node_modules/fictional-package", extracted) + .is_err(), + "should reject changed manifest bytes" + ); + assert!( + catalog + .review_npm("node_modules/other", extract_package()) + .is_err(), + "should reject another locked location" + ); + } +} + +#[test] +fn enforces_decoded_byte_and_entry_budgets() { + let bytes = compressed(&tar_bytes(&[ + ("package/file", b'0', 0o644, b"x"), + ("package/other", b'0', 0o644, b"y"), + ])); + assert!( + extract_bounded( + original(&bytes), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + }, + 1 + ) + .is_err(), + "should reject entry exceeding bounded parser budget" + ); + let original = original(&bytes); + let mut decoded = Decoded { + reader: io::repeat(0).take(DECODED_LIMIT + 1), + original: &original, + count: 0, + }; + assert!( + io::copy(&mut decoded, &mut io::sink()).is_err(), + "should stop unbounded decoded expansion" + ); +} + +#[test] +fn extracted_tree_review_rejects_changed_or_added_members() { + let bytes = compressed(&tar_bytes(&[( + "package/index.js", + b'0', + 0o644, + b"original", + )])); + let extracted = extract( + original(&bytes), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + }, + ) + .expect("should extract original"); + extracted + .verify_unchanged() + .expect("should verify original tree"); + fs::write(extracted.root().join("added.js"), b"added").expect("should add unrecorded file"); + assert!( + extracted.verify_unchanged().is_err(), + "should reject unrecorded member" + ); + fs::remove_file(extracted.root().join("added.js")).expect("should remove extra file"); + fs::write(extracted.root().join("index.js"), b"changed").expect("should change recorded file"); + assert!( + extracted.verify_unchanged().is_err(), + "should reject changed recorded member" + ); +} + +#[test] +fn npm_bundle_rechecks_and_retains_originals_for_independent_caches() { + let manifest = serde_json::to_vec(&json!({"name":"fictional-package","version":"1.0.0"})) + .expect("should encode manifest"); + let bytes = compressed(&tar_bytes(&[( + "package/package.json", + b'0', + 0o644, + &manifest, + )])); + let catalog = catalog(&bytes); + let inputs = tempfile::tempdir().expect("should create input directory"); + let name = format!("{}.tgz", hex::encode(Sha512::digest(&bytes))); + fs::write(inputs.path().join(&name), &bytes).expect("should write original"); + let prepared = catalog + .prepare_npm_originals( + inputs.path(), + Instant::now() + Duration::from_secs(10), + &Cancellation::default(), + ) + .expect("should authenticate complete original set"); + assert_eq!( + prepared.archive_paths().len(), + 1, + "should retain every locked original" + ); + assert_eq!( + fs::read(&prepared.archive_paths()[0]).expect("should read retained original"), + bytes, + "should retain compressed identity rather than repacked bytes" + ); + fs::write(inputs.path().join(name), b"changed").expect("should replace caller input"); + prepared + .verify_unchanged() + .expect("should own originals independently of caller input"); + assert!( + catalog + .prepare_npm_originals( + inputs.path(), + Instant::now() + Duration::from_secs(10), + &Cancellation::default() + ) + .is_err(), + "should reject changed original on next preparation" + ); +} + +#[test] +fn extraction_recheck_rejects_root_alias_and_missing_empty_directory() { + let bytes = compressed(&tar_bytes(&[ + ("package/index.js", b'0', 0o644, b"original"), + ("package/empty", b'5', 0o755, b""), + ])); + let extracted = extract( + original(&bytes), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package: false, + }, + ) + .expect("should extract original"); + extracted + .verify_unchanged() + .expect("should recheck explicit empty directory"); + fs::remove_dir(extracted.root().join("empty")).expect("should remove empty directory"); + assert!( + extracted.verify_unchanged().is_err(), + "should reject missing recorded empty directory" + ); + fs::create_dir(extracted.root().join("empty")).expect("should restore directory"); + let saved = extracted.directory.path().join("saved"); + fs::rename(extracted.root(), &saved).expect("should move original root"); + std::os::unix::fs::symlink("saved", extracted.root()) + .expect("should substitute matching root alias"); + assert!( + extracted.verify_unchanged().is_err(), + "should reject replaced extraction root even with identical members" + ); +} + +mod cargo_package; +mod npm_compatibility; diff --git a/crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs b/crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs new file mode 100644 index 000000000..992e211a2 --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs @@ -0,0 +1,154 @@ +//! Encodings `cargo package` writes into registry crates: GNU long names and +//! file-type bits in the mode field. + +use super::*; + +fn crate_selection(cargo_package: bool) -> Selection<'static> { + Selection { + root: "example-1.0.0", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package, + } +} + +/// A member path longer than the 100-byte header name field. +fn long_member() -> String { + format!("example-1.0.0/{}/file.rs", "nested".repeat(18)) +} + +/// A crate as `cargo package` writes it: a GNU long-name record and a +/// regular file whose mode carries the regular-file type bits. +fn cargo_package_archive() -> Vec { + let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast())); + let mut long = tar::Header::new_gnu(); + long.set_mode(0o644); + long.set_size(5); + builder + .append_data(&mut long, long_member(), &b"long\n"[..]) + .expect("should append a GNU long-name member"); + let mut typed = tar::Header::new_ustar(); + typed.set_mode(0o100_755); + typed.set_size(4); + typed.set_cksum(); + builder + .append_data(&mut typed, "example-1.0.0/build.sh", &b"#!\n\n"[..]) + .expect("should append a typed-mode member"); + builder + .into_inner() + .expect("should finish crate TAR") + .finish() + .expect("should finish crate gzip") +} + +#[test] +fn cargo_package_long_names_and_typed_modes_extract_only_for_crates() { + let bytes = cargo_package_archive(); + let extracted = extract(original(&bytes), crate_selection(true)) + .expect("should extract the encodings cargo package writes"); + assert_eq!( + fs::read(extracted.directory.path().join(long_member())).expect("should read long member"), + b"long\n", + "should place the member at its full long name" + ); + let (_, _, mode) = extracted + .member_facts("build.sh") + .expect("should record the typed-mode member"); + assert_eq!(mode, 0o755, "should keep only the permission bits"); + let error = extract(original(&bytes), crate_selection(false)) + .err() + .expect("should keep npm extraction closed to both encodings"); + assert!( + format!("{error:?}").contains("GNU long-name"), + "should name the unreviewed record: {error:?}" + ); +} + +/// One raw TAR member: name, entry type, mode and contents. +type Member<'a> = (&'a str, u8, u32, &'a [u8]); + +#[test] +fn cargo_package_encodings_never_rename_or_privilege_a_member() { + let long = long_member(); + let record = format!("{long}\0"); + let header_name = &long[..100]; + let other_name = format!("example-1.0.0/{}", "x".repeat(86)); + let control = compressed(&tar_bytes(&[ + ("././@LongLink", b'L', 0o644, record.as_bytes()), + (header_name, b'0', 0o100_644, b"data"), + ])); + let extracted = extract(original(&control), crate_selection(true)) + .expect("should accept the hand-built record the negatives below alter"); + assert!( + extracted.directory.path().join(&long).is_file(), + "should extract the control member at its long name" + ); + let cases: [(&str, Vec>); 9] = [ + ( + "renaming header", + vec![ + ("././@LongLink", b'L', 0o644, record.as_bytes()), + (other_name.as_str(), b'0', 0o644, b"data"), + ], + ), + ( + "consecutive records", + vec![ + ("././@LongLink", b'L', 0o644, record.as_bytes()), + ("././@LongLink", b'L', 0o644, record.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "dangling record", + vec![ + ("example-1.0.0/lib.rs", b'0', 0o644, b"data"), + ("././@LongLink", b'L', 0o644, record.as_bytes()), + ], + ), + ( + "unterminated record", + vec![ + ("././@LongLink", b'L', 0o644, long.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "foreign record name", + vec![ + ("example-1.0.0/LongLink", b'L', 0o644, record.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "directory-mode record", + vec![ + ("././@LongLink", b'L', 0o040_755, record.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "set-user-ID record", + vec![ + ("././@LongLink", b'L', 0o104_644, record.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "symlink type bits", + vec![("example-1.0.0/lib.rs", b'0', 0o120_777, b"data")], + ), + ( + "set-user-ID mode", + vec![("example-1.0.0/lib.rs", b'0', 0o104_755, b"data")], + ), + ]; + for (label, entries) in cases { + let bytes = compressed(&tar_bytes(&entries)); + assert!( + extract(original(&bytes), crate_selection(true)).is_err(), + "should reject a crate with a {label}" + ); + } +} diff --git a/crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs b/crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs new file mode 100644 index 000000000..9e1c9cdb9 --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs @@ -0,0 +1,177 @@ +//! Reviewed npm compatibility: node-tar timestamp prefixes and local PAX headers. + +use super::*; + +#[test] +fn accepts_only_reviewed_node_tar_timestamp_layout() { + let mut header = tar::Header::new_ustar(); + header.as_mut_bytes()[476..488].copy_from_slice(b"15075543223\0"); + header.as_mut_bytes()[488..500].copy_from_slice(b"15075543223\0"); + header.as_mut_bytes()[..12].copy_from_slice(b"chai/file.js"); + let selection = Selection { + root: "chai", + index_alias: false, + node_tar_timestamps: true, + local_pax: false, + cargo_package: false, + }; + assert_eq!( + raw_name(&header, &selection).expect("should parse reviewed layout"), + "chai/file.js" + ); + assert!( + raw_name( + &header, + &Selection { + node_tar_timestamps: false, + ..selection + } + ) + .is_err(), + "should reject layout outside approved tuple" + ); + header.as_mut_bytes()[475] = b'x'; + assert!( + raw_name(&header, &selection).is_err(), + "should reject hidden prefix bytes" + ); + header.as_mut_bytes()[475] = 0; + header.as_mut_bytes()[476] = b'9'; + assert!( + raw_name(&header, &selection).is_err(), + "should reject nonoctal timestamps" + ); +} + +fn pax_record(key: &str, value: &str) -> Vec { + let content = format!(" {key}={value}\n"); + let mut length = content.len() + 1; + loop { + let next = content.len() + length.to_string().len(); + if next == length { + return format!("{length}{content}").into_bytes(); + } + length = next; + } +} +#[test] +fn reviewed_local_pax_never_overrides_member_selection() { + let mut body = pax_record("path", "package/file.js"); + body.extend(pax_record("size", "3")); + body.extend(pax_record("NODETAR.type", "File")); + let parsed = compatibility::parse_pax(&body).expect("should parse reviewed metadata"); + assert!( + parsed.join("package/file.js", 3).is_ok(), + "should accept redundant selectors" + ); + assert!( + parsed.join("package/other.js", 3).is_err(), + "should reject path override" + ); + assert!( + parsed.join("package/file.js", 4).is_err(), + "should reject size override" + ); + for (key, value) in [ + ("linkpath", "package/other"), + ("GNU.sparse.size", "3"), + ("size", "03"), + ] { + let mut invalid = body.clone(); + invalid.extend(pax_record(key, value)); + assert!( + compatibility::parse_pax(&invalid).is_err(), + "should reject semantic or duplicate fields" + ); + } + assert!( + compatibility::parse_pax(b"99 path=package/file.js\n").is_err(), + "should reject malformed framing" + ); +} + +#[test] +fn reviewed_pax_is_local_bounded_and_never_redirects_extraction() { + let metadata = |path: &str, size: &str| { + let mut bytes = pax_record("path", path); + bytes.extend(pax_record("size", size)); + bytes.extend(pax_record("NODETAR.type", "File")); + bytes + }; + let valid = metadata("package/file.js", "3"); + let selection = Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: true, + cargo_package: false, + }; + let archive = compressed(&tar_bytes(&[ + ("PaxHeader/package/file.js", b'x', 0o644, &valid), + ("package/file.js", b'0', 0o644, b"abc"), + ])); + let extracted = + extract(original(&archive), selection).expect("should extract redundant local metadata"); + extracted + .verify_unchanged() + .expect("should preserve regular member facts"); + let bad_path = metadata("package/other.js", "3"); + let bad_size = metadata("package/file.js", "4"); + let cases = [ + vec![("PaxHeader/package/file.js", b'x', 0o644, valid.as_slice())], + vec![ + ("PaxHeader/package/file.js", b'x', 0o644, valid.as_slice()), + ("PaxHeader/other.js", b'x', 0o644, valid.as_slice()), + ], + vec![ + ("PaxHeader/package/file.js", b'g', 0o644, valid.as_slice()), + ("package/file.js", b'0', 0o644, b"abc"), + ], + vec![ + ( + "PaxHeader/package/file.js", + b'x', + 0o644, + bad_path.as_slice(), + ), + ("package/file.js", b'0', 0o644, b"abc"), + ], + vec![ + ( + "PaxHeader/package/file.js", + b'x', + 0o644, + bad_size.as_slice(), + ), + ("package/file.js", b'0', 0o644, b"abc"), + ], + ]; + for entries in cases { + let bytes = compressed(&tar_bytes(&entries)); + assert!( + extract(original(&bytes), selection).is_err(), + "should reject nonlocal or overriding metadata" + ); + } + for (name, mode) in [ + ("PaxHeader/../file.js", 0o644), + ("PaxHeader/package/file.js", 0o4644), + ] { + let bytes = compressed(&tar_bytes(&[ + (name, b'x', mode, &valid), + ("package/file.js", b'0', 0o644, b"abc"), + ])); + assert!( + extract(original(&bytes), selection).is_err(), + "should reject unsafe PAX header itself" + ); + } + let unreviewed = Selection { + local_pax: false, + ..selection + }; + assert!( + extract(original(&archive), unreviewed).is_err(), + "should reject PAX outside exact approved tuple" + ); +} diff --git a/crates/trusted-server-release/src/materials/git.rs b/crates/trusted-server-release/src/materials/git.rs new file mode 100644 index 000000000..21e74bce6 --- /dev/null +++ b/crates/trusted-server-release/src/materials/git.rs @@ -0,0 +1,530 @@ +//! Locked Git commit snapshots, distinct from acquisition and Cargo checkouts. + +mod input; + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use error_stack::Report; +use flate2::Compression; +use flate2::write::ZlibEncoder; +use sha2::{Digest as _, Sha256}; + +use super::{LockedMaterials, material_error}; +use crate::error::ReleaseError; +use crate::git_objects::ObjectReader; +use crate::process::{BoundedCommand, Cancellation, ProcessLimits, ProcessResult}; + +const TREE_DEPTH: usize = 256; +const TREE_ENTRIES: usize = 100_000; + +/// One bounded collection shares its decoded object budget across dependencies. +/// +/// A locally selected executable is not an authenticated release-tool selection. +pub(crate) struct GitCollection<'a> { + locked: &'a LockedMaterials, + program: PathBuf, + program_sha256: String, + deadline: Instant, + cancellation: Cancellation, + objects: ObjectReader, +} + +/// A selected commit's complete verified closure in an owned private object store. +/// +/// Parent history and unselected objects are deliberately absent. This value +/// proves an object snapshot, not an installed Cargo checkout or tool qualification. +pub(crate) struct VerifiedGitMaterial { + source_commit: String, + cargo_lock_sha256: String, + selector: String, + revision: String, + root_tree: String, + store: tempfile::TempDir, + object_ids: BTreeSet, +} + +impl VerifiedGitMaterial { + /// Wraps a hand-built loose-object store without verifying it, for tests. + #[cfg(test)] + pub(super) fn for_tests( + source_commit: &str, + cargo_lock_sha256: &str, + selector: &str, + revision: &str, + root_tree: &str, + store: tempfile::TempDir, + ) -> Self { + Self { + source_commit: source_commit.to_owned(), + cargo_lock_sha256: cargo_lock_sha256.to_owned(), + selector: selector.to_owned(), + revision: revision.to_owned(), + root_tree: root_tree.to_owned(), + store, + object_ids: BTreeSet::new(), + } + } + + /// Returns the independently approved source binding. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } + /// Returns the approved raw Cargo lockfile digest. + pub(crate) fn cargo_lock_sha256(&self) -> &str { + &self.cargo_lock_sha256 + } + /// Returns the exact locked Git source selector. + pub(crate) fn selector(&self) -> &str { + &self.selector + } + /// Returns the exact locked commit identity. + pub(crate) fn revision(&self) -> &str { + &self.revision + } + /// Returns the selected commit's collision-checked root tree identity. + pub(crate) fn root_tree(&self) -> &str { + &self.root_tree + } + /// Borrows the owned verified-only object directory, never the input store. + pub(crate) fn object_store(&self) -> &Path { + self.store.path() + } + /// Returns the verified selected closure's distinct object count. + pub(crate) fn object_count(&self) -> usize { + self.object_ids.len() + } +} + +fn program_digest( + path: &Path, + deadline: Instant, + cancellation: &Cancellation, +) -> Result> { + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(material_error)?; + let metadata = file.metadata().map_err(material_error)?; + if !metadata.is_file() + || metadata.permissions().mode() & 0o111 == 0 + || metadata.len() > 64 * 1024 * 1024 + { + return Err(material_error( + "selected Git must be a bounded regular executable", + )); + } + let mut digest = Sha256::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(material_error( + "Git executable verification cancelled or timed out", + )); + } + let count = file.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + length += count as u64; + if length > metadata.len() { + return Err(material_error("selected Git executable grew")); + } + digest.update(&buffer[..count]); + } + let after = file.metadata().map_err(material_error)?; + let stamp = |value: &std::fs::Metadata| { + ( + value.dev(), + value.ino(), + value.nlink(), + value.len(), + value.mode(), + value.mtime(), + value.mtime_nsec(), + value.ctime(), + value.ctime_nsec(), + ) + }; + if length != metadata.len() || stamp(&metadata) != stamp(&after) { + return Err(material_error("selected Git executable changed")); + } + Ok(hex::encode(digest.finalize())) +} + +impl<'a> GitCollection<'a> { + pub(super) fn new( + locked: &'a LockedMaterials, + program: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + if !program.is_absolute() { + return Err(material_error("selected Git path must be absolute")); + } + let program = fs::canonicalize(program).map_err(material_error)?; + let program_sha256 = program_digest(&program, deadline, cancellation)?; + let collection = Self { + locked, + program, + program_sha256, + deadline, + cancellation: cancellation.clone(), + objects: ObjectReader::cargo_materials(), + }; + collection.check()?; + Ok(collection) + } + + fn check(&self) -> Result<(), Report> { + if self.cancellation.is_cancelled() || Instant::now() >= self.deadline { + return Err(material_error( + "Git material verification cancelled or timed out", + )); + } + Ok(()) + } + + fn execute( + &self, + repository: &Path, + arguments: &[&str], + input: Vec, + limit: u64, + ) -> Result, Report> { + self.check()?; + if program_digest(&self.program, self.deadline, &self.cancellation)? != self.program_sha256 + { + return Err(material_error("selected Git executable changed")); + } + let home = repository.join("home"); + let mut git_dir = OsString::from("--git-dir="); + git_dir.push(repository); + let mut fixed = vec![git_dir, OsString::from("--no-replace-objects")]; + fixed.extend( + [ + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "-c", + "protocol.allow=never", + ] + .into_iter() + .chain(arguments.iter().copied()) + .map(OsString::from), + ); + let wall = self + .deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| material_error("Git material deadline expired"))?; + let output = BoundedCommand::new( + &self.program, + repository, + ProcessLimits { + wall: wall.min(Duration::from_secs(120)), + stdout_max: limit, + stderr_max: 64 * 1024, + }, + )? + .arguments(fixed) + .environment("HOME", &home)? + .environment( + "PATH", + self.program + .parent() + .ok_or_else(|| material_error("Git has no parent"))?, + )? + .environment("GIT_CONFIG_NOSYSTEM", "1")? + .environment("GIT_NO_REPLACE_OBJECTS", "1")? + .environment("GIT_TERMINAL_PROMPT", "0")? + .environment("GIT_OPTIONAL_LOCKS", "0")? + .environment("LC_ALL", "C")? + .stdin_bytes(input) + .cancellation(self.cancellation.clone()) + .run() + .and_then(ProcessResult::require_success) + .map_err(|report| report.change_context(ReleaseError::Material))?; + self.check()?; + Ok(output) + } + + fn read( + &self, + repository: &Path, + kind: &'static str, + oid: &str, + ) -> Result, Report> { + self.check()?; + let mut body = Vec::new(); + self.objects + .read_objects( + kind, + &[oid], + &mut |arguments, input, limit| self.execute(repository, arguments, input, limit), + |_, bytes| { + body.extend_from_slice(bytes); + Ok(()) + }, + ) + .map_err(|report| report.change_context(ReleaseError::Material))?; + Ok(body) + } + + fn retain( + &self, + store: &Path, + kind: &str, + oid: &str, + bytes: &[u8], + ) -> Result<(), Report> { + self.check()?; + let directory = store.join(&oid[..2]); + match fs::create_dir(&directory) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(material_error(error)), + } + let output = File::create_new(directory.join(&oid[2..])).map_err(material_error)?; + let mut encoded = ZlibEncoder::new(output, Compression::default()); + encoded + .write_all(format!("{kind} {}\0", bytes.len()).as_bytes()) + .map_err(material_error)?; + for chunk in bytes.chunks(64 * 1024) { + self.check()?; + encoded.write_all(chunk).map_err(material_error)?; + } + encoded.finish().map_err(material_error)?; + self.check() + } + + /// Verifies only an approved locked selector and its full commit identity. + /// + /// Input objects are copied as untrusted data. Their repository configuration, + /// refs, hooks and alternates are never consulted. All selected trees and blobs + /// are independently rehashed, including subtrees outside Cargo package roots. + /// + /// # Errors + /// + /// Returns an error for an unlocked selection, unsafe input, changed executable, + /// missing/corrupt objects, unsafe tree entry or exceeded shared limits. + pub(crate) fn verify( + &self, + selector: &str, + revision: &str, + objects: &Path, + ) -> Result> { + self.check()?; + if !lower_oid(revision.as_bytes()) { + return Err(material_error( + "Git revision must be a full canonical object identity", + )); + } + if !self + .locked + .cargo + .git + .contains(&(selector.to_owned(), revision.to_owned())) + { + return Err(material_error( + "Git selection is absent from approved Cargo lockfile", + )); + } + let repository = tempfile::Builder::new() + .prefix("release-git-input-") + .tempdir() + .map_err(material_error)?; + fs::create_dir(repository.path().join("objects")).map_err(material_error)?; + fs::create_dir(repository.path().join("refs")).map_err(material_error)?; + fs::create_dir(repository.path().join("home")).map_err(material_error)?; + fs::write(repository.path().join("HEAD"), b"ref: refs/heads/unused\n") + .map_err(material_error)?; + fs::write( + repository.path().join("config"), + b"[core]\nrepositoryformatversion = 0\nbare = true\n", + ) + .map_err(material_error)?; + input::copy(objects, &repository.path().join("objects"), self)?; + let store = tempfile::Builder::new() + .prefix("release-git-verified-") + .tempdir() + .map_err(material_error)?; + let commit = self.read(repository.path(), "commit", revision)?; + let root_tree = commit_tree(&commit)?; + self.retain(store.path(), "commit", revision, &commit)?; + let mut object_kinds = BTreeMap::from([(revision.to_owned(), "commit")]); + let mut trees = BTreeMap::>::new(); + let mut pending = vec![(root_tree.clone(), 1_usize, 0_usize)]; + let mut entry_count = 0_usize; + while let Some((oid, depth, prefix_length)) = pending.pop() { + self.check()?; + if depth > TREE_DEPTH { + return Err(material_error("Git dependency tree exceeds depth limit")); + } + let entries = if let Some(entries) = trees.get(&oid) { + entries.clone() + } else { + let bytes = self.read(repository.path(), "tree", &oid)?; + let entries = parse_tree(&bytes)?; + self.retain(store.path(), "tree", &oid, &bytes)?; + if object_kinds.insert(oid.clone(), "tree").is_some() { + return Err(material_error( + "Git object is referenced with conflicting types", + )); + } + trees.insert(oid, entries.clone()); + entries + }; + entry_count = entry_count + .checked_add(entries.len()) + .ok_or_else(|| material_error("tree entry count overflow"))?; + if entry_count > TREE_ENTRIES { + return Err(material_error("Git dependency tree exceeds entry limit")); + } + for entry in entries { + let path_length = prefix_length + entry.name_length; + if path_length > 4096 { + return Err(material_error("Git dependency path exceeds byte limit")); + } + if entry.tree { + pending.push((entry.oid, depth + 1, path_length + 1)); + } else if let Some(kind) = object_kinds.get(&entry.oid) { + if *kind != "blob" { + return Err(material_error( + "Git object is referenced with conflicting types", + )); + } + } else { + let bytes = self.read(repository.path(), "blob", &entry.oid)?; + self.retain(store.path(), "blob", &entry.oid, &bytes)?; + object_kinds.insert(entry.oid, "blob"); + } + } + } + self.check()?; + Ok(VerifiedGitMaterial { + source_commit: self.locked.source_commit.clone(), + cargo_lock_sha256: self.locked.cargo_lock_sha256.clone(), + selector: selector.to_owned(), + revision: revision.to_owned(), + root_tree, + store, + object_ids: object_kinds.into_keys().collect(), + }) + } +} + +fn lower_oid(bytes: &[u8]) -> bool { + bytes.len() == 40 + && bytes + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) +} + +fn commit_tree(bytes: &[u8]) -> Result> { + if bytes.contains(&0) { + return Err(material_error("Git commit contains a NUL")); + } + let end = bytes + .windows(2) + .position(|window| window == b"\n\n") + .ok_or_else(|| material_error("Git commit has no header terminator"))?; + let mut headers = bytes[..end].split(|byte| *byte == b'\n'); + let tree = headers + .next() + .and_then(|line| line.strip_prefix(b"tree ")) + .filter(|oid| lower_oid(oid)) + .ok_or_else(|| material_error("Git commit has no canonical root tree"))?; + if headers.any(|line| line.starts_with(b"tree ")) { + return Err(material_error("Git commit repeats its root tree")); + } + String::from_utf8(tree.to_vec()).map_err(material_error) +} + +#[derive(Clone)] +struct Entry { + tree: bool, + oid: String, + name_length: usize, +} + +fn parse_tree(mut bytes: &[u8]) -> Result, Report> { + let mut names = BTreeSet::new(); + let mut previous = None::>; + let mut entries = Vec::new(); + while !bytes.is_empty() { + let space = bytes + .iter() + .position(|byte| *byte == b' ') + .ok_or_else(|| material_error("malformed Git tree mode"))?; + let mode = &bytes[..space]; + let tree = match mode { + b"40000" => true, + // Link targets remain authenticated blob data; no filesystem link is created. + b"100644" | b"100755" | b"120000" => false, + _ => { + return Err(material_error( + "Git dependency submodules and noncanonical modes are unreviewed", + )); + } + }; + let rest = &bytes[space + 1..]; + let nul = rest + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| material_error("malformed Git tree name"))?; + let name = &rest[..nul]; + if name.is_empty() + || !name.is_ascii() + || name + .iter() + .any(|byte| byte.is_ascii_control() || matches!(byte, b'/' | b'\\' | b':')) + || matches!(name, b"." | b"..") + || name.eq_ignore_ascii_case(b".git") + || name.ends_with(b".") + || name.ends_with(b" ") + || name.len() > 255 + { + return Err(material_error("unsafe Git dependency tree name")); + } + let folded: Vec<_> = name.iter().map(u8::to_ascii_lowercase).collect(); + if !names.insert(folded) { + return Err(material_error( + "Git dependency tree aliases or repeats a name", + )); + } + let mut key = name.to_vec(); + if tree { + key.push(b'/'); + } + if previous.as_ref().is_some_and(|previous| *previous >= key) { + return Err(material_error("Git dependency tree order is noncanonical")); + } + previous = Some(key); + let raw_oid = rest + .get(nul + 1..nul + 21) + .ok_or_else(|| material_error("truncated Git tree identity"))?; + entries.push(Entry { + tree, + oid: hex::encode(raw_oid), + name_length: name.len(), + }); + if entries.len() > TREE_ENTRIES { + return Err(material_error("Git tree exceeds entry limit")); + } + bytes = &rest[nul + 21..]; + } + Ok(entries) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/materials/git/input.rs b/crates/trusted-server-release/src/materials/git/input.rs new file mode 100644 index 000000000..149973f16 --- /dev/null +++ b/crates/trusted-server-release/src/materials/git/input.rs @@ -0,0 +1,260 @@ +//! Object-only copies anchored to held descriptors, never input Git config. + +use std::collections::BTreeSet; +use std::ffi::{CStr, CString}; +use std::fs::{self, File, Metadata}; +use std::io::{Read as _, Write as _}; +use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::MetadataExt as _; +use std::path::{Component, Path}; + +use error_stack::Report; + +use super::{GitCollection, material_error}; +use crate::error::ReleaseError; + +const RAW_LIMIT: u64 = 512 * 1024 * 1024; +const ENTRY_LIMIT: usize = 100_000; + +fn stamp(metadata: &Metadata) -> (u64, u64, u64, u64, i64, i64, i64, i64) { + ( + metadata.dev(), + metadata.ino(), + metadata.nlink(), + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) +} + +/// Opens each ancestor without following a symlink or accepting relative syntax. +fn anchor(path: &Path) -> Result> { + if !path.is_absolute() || path.as_os_str().as_bytes().contains(&0) { + return Err(material_error( + "Git object root must be an absolute canonical path", + )); + } + let mut directory = File::open("/").map_err(material_error)?; + for component in path.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => { + directory = open_at(&directory, name.as_bytes(), true)?; + } + _ => return Err(material_error("noncanonical Git object root")), + } + } + Ok(directory) +} + +fn open_at(parent: &File, name: &[u8], directory: bool) -> Result> { + let name = CString::new(name).map_err(material_error)?; + let flags = libc::O_RDONLY + | libc::O_NOFOLLOW + | libc::O_NONBLOCK + | libc::O_CLOEXEC + | if directory { libc::O_DIRECTORY } else { 0 }; + // The parent descriptor stays alive throughout openat, and name is NUL terminated. + let descriptor = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) }; + if descriptor < 0 { + return Err(material_error(std::io::Error::last_os_error())); + } + // A successful openat transfers one fresh descriptor to this File. + Ok(unsafe { File::from_raw_fd(descriptor) }) +} + +struct Directory(*mut libc::DIR); + +impl Drop for Directory { + fn drop(&mut self) { + // fdopendir returned this stream, which is closed exactly once. + unsafe { libc::closedir(self.0) }; + } +} + +#[cfg(target_os = "macos")] +fn errno() -> *mut libc::c_int { + // libc exposes the calling thread's errno location. + unsafe { libc::__error() } +} + +#[cfg(target_os = "linux")] +fn errno() -> *mut libc::c_int { + // libc exposes the calling thread's errno location. + unsafe { libc::__errno_location() } +} + +fn names( + directory: &File, + collection: &GitCollection<'_>, +) -> Result, Report> { + // A duplicate keeps fdopendir ownership separate from the held anchor. + let descriptor = unsafe { libc::fcntl(directory.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + if descriptor < 0 { + return Err(material_error(std::io::Error::last_os_error())); + } + // A successful fcntl returns a fresh owned descriptor. + let owned = unsafe { OwnedFd::from_raw_fd(descriptor) }; + // fdopendir takes ownership only on success. + let stream = unsafe { libc::fdopendir(owned.as_raw_fd()) }; + if stream.is_null() { + return Err(material_error(std::io::Error::last_os_error())); + } + std::mem::forget(owned); + let stream = Directory(stream); + let mut output = Vec::new(); + let mut seen = BTreeSet::new(); + loop { + collection.check()?; + // Reset errno before readdir so EOF can be distinguished from failure. + unsafe { *errno() = 0 }; + // The stream remains owned until all returned name bytes are copied. + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + // readdir sets errno on error and leaves the cleared value at EOF. + if unsafe { *errno() } != 0 { + return Err(material_error(std::io::Error::last_os_error())); + } + break; + } + // readdir supplies a NUL-terminated d_name valid until its next call. + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if matches!(name, b"." | b"..") { + continue; + } + let name = std::str::from_utf8(name) + .map_err(material_error)? + .to_owned(); + if !seen.insert(name.clone()) || output.len() >= ENTRY_LIMIT { + return Err(material_error( + "Git object directory repeats names or exceeds entry limit", + )); + } + output.push(name); + } + output.sort(); + Ok(output) +} + +fn hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +struct CopyBudget { + bytes: u64, + entries: usize, +} + +fn copy_file( + parent: &File, + name: &str, + destination: &Path, + budget: &mut CopyBudget, + collection: &GitCollection<'_>, +) -> Result<(), Report> { + collection.check()?; + let mut input = open_at(parent, name.as_bytes(), false)?; + let before = input.metadata().map_err(material_error)?; + if !before.is_file() || before.nlink() != 1 || before.len() > RAW_LIMIT - budget.bytes { + return Err(material_error( + "Git object input must be a bounded regular single-link file", + )); + } + budget.entries += 1; + if budget.entries > ENTRY_LIMIT { + return Err(material_error("Git object input exceeds entry limit")); + } + let mut output = File::create_new(destination).map_err(material_error)?; + let mut copied = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + collection.check()?; + let count = input.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + copied += count as u64; + if copied > before.len() { + return Err(material_error("Git object input grew")); + } + output.write_all(&buffer[..count]).map_err(material_error)?; + } + if copied != before.len() || stamp(&before) != stamp(&input.metadata().map_err(material_error)?) + { + return Err(material_error("Git object input changed while copying")); + } + budget.bytes += copied; + Ok(()) +} + +/// Copies only canonical loose and pack objects under independent raw limits. +pub(super) fn copy( + input: &Path, + destination: &Path, + collection: &GitCollection<'_>, +) -> Result<(), Report> { + collection.check()?; + let root = anchor(input)?; + let before = root.metadata().map_err(material_error)?; + let mut budget = CopyBudget { + bytes: 0, + entries: 0, + }; + for name in names(&root, collection)? { + if !(hex(&name, 2) || name == "pack" || name == "info") { + return Err(material_error("unsupported Git object input entry")); + } + let directory = open_at(&root, name.as_bytes(), true)?; + let directory_before = directory.metadata().map_err(material_error)?; + let children = names(&directory, collection)?; + fs::create_dir(destination.join(&name)).map_err(material_error)?; + let mut pack = BTreeSet::new(); + let mut index = BTreeSet::new(); + for child in children { + if name == "info" { + return Err(material_error("Git object info/indirection is forbidden")); + } + if name == "pack" { + let (base, extension) = child + .rsplit_once('.') + .ok_or_else(|| material_error("noncanonical Git pack name"))?; + if !base.strip_prefix("pack-").is_some_and(|oid| hex(oid, 40)) + || !matches!(extension, "pack" | "idx" | "rev") + { + return Err(material_error("unsupported Git pack entry")); + } + if extension == "pack" { + pack.insert(base.to_owned()); + } + if extension == "idx" { + index.insert(base.to_owned()); + } + } else if !hex(&child, 38) { + return Err(material_error("noncanonical loose Git object name")); + } + copy_file( + &directory, + &child, + &destination.join(&name).join(&child), + &mut budget, + collection, + )?; + } + if name == "pack" && pack != index { + return Err(material_error("Git pack and index sets differ")); + } + if stamp(&directory_before) != stamp(&directory.metadata().map_err(material_error)?) { + return Err(material_error("Git object directory changed while copying")); + } + } + if stamp(&before) != stamp(&root.metadata().map_err(material_error)?) { + return Err(material_error("Git object root changed while copying")); + } + collection.check() +} diff --git a/crates/trusted-server-release/src/materials/git/tests.rs b/crates/trusted-server-release/src/materials/git/tests.rs new file mode 100644 index 000000000..98414e0df --- /dev/null +++ b/crates/trusted-server-release/src/materials/git/tests.rs @@ -0,0 +1,876 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; +use std::time::{Duration, Instant}; + +use flate2::Compression; +use flate2::write::ZlibEncoder; + +use super::*; +use crate::git_objects::checked_sha1; +use crate::materials::locked::CargoMaterials; + +const SELECTOR: &str = "git+https://example.com/fictional?rev=locked"; + +fn temporary() -> tempfile::TempDir { + let root = fs::canonicalize(std::env::temp_dir()).expect("should resolve temporary parent"); + tempfile::tempdir_in(root).expect("should create canonical temporary directory") +} + +fn object(root: &Path, kind: &str, bytes: &[u8]) -> String { + let header = format!("{kind} {}\0", bytes.len()); + let oid = checked_sha1(&[header.as_bytes(), bytes]).expect("should hash fixture object"); + let path = root.join(&oid[..2]).join(&oid[2..]); + fs::create_dir_all(path.parent().expect("should have object parent")) + .expect("should create object directory"); + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(header.as_bytes()) + .expect("should encode header"); + encoder.write_all(bytes).expect("should encode body"); + fs::write(path, encoder.finish().expect("should finish object")) + .expect("should write fixture object"); + oid +} + +fn fixture(root: &Path) -> String { + let blob = object(root, "blob", b"fictional dependency\n"); + let mut tree = b"100644 Cargo.toml\0".to_vec(); + tree.extend(hex::decode(blob).expect("should decode object id")); + let tree = object(root, "tree", &tree); + object(root, "commit", format!("tree {tree}\nauthor Fictional 1 +0000\ncommitter Fictional 1 +0000\n\nfictional commit\n").as_bytes()) +} + +fn locked(revision: &str) -> LockedMaterials { + LockedMaterials { + source_commit: "a".repeat(40), + cargo_lock_sha256: "b".repeat(64), + npm_lock_sha256: "c".repeat(64), + cargo: CargoMaterials { + registry: BTreeMap::new(), + git: BTreeSet::from([(SELECTOR.to_owned(), revision.to_owned())]), + git_packages: Vec::new(), + }, + npm: BTreeMap::new(), + } +} + +#[test] +fn complete_loose_snapshot_is_verified_from_locked_commit() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let snapshot = locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should select local Git fixture") + .verify(SELECTOR, &revision, input.path()) + .expect("should verify all selected objects"); + assert_eq!( + snapshot.source_commit(), + "a".repeat(40), + "should bind source" + ); + assert_eq!( + snapshot.cargo_lock_sha256(), + "b".repeat(64), + "should bind raw lockfile" + ); + assert_eq!(snapshot.selector(), SELECTOR, "should bind exact selector"); + assert_eq!(snapshot.revision(), revision, "should bind locked commit"); + assert_eq!( + snapshot.root_tree().len(), + 40, + "should retain root identity" + ); + assert_eq!(snapshot.object_count(), 3, "should retain the full closure"); + assert_ne!( + snapshot.object_store(), + input.path(), + "should own a new store" + ); +} + +fn collection(locked: &LockedMaterials) -> GitCollection<'_> { + locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() + Duration::from_secs(60), + &Cancellation::default(), + ) + .expect("should select Git for a bounded local fixture") +} + +fn commit(root: &Path, tree: &str) -> String { + object(root, "commit", format!("tree {tree}\nauthor Fictional 1 +0000\ncommitter Fictional 1 +0000\n\nfixture\n").as_bytes()) +} + +fn tree(root: &Path, entries: &[(&[u8], &[u8], &str)]) -> String { + let mut bytes = Vec::new(); + for (mode, name, oid) in entries { + bytes.extend_from_slice(mode); + bytes.push(b' '); + bytes.extend_from_slice(name); + bytes.push(0); + bytes.extend(hex::decode(oid).expect("should decode fixture identity")); + } + object(root, "tree", &bytes) +} + +#[test] +fn unselected_objects_are_not_retained_and_input_replacement_is_irrelevant() { + let input = temporary(); + let revision = fixture(input.path()); + let extra = object(input.path(), "blob", b"unselected material"); + let locked = locked(&revision); + let snapshot = collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .expect("should verify selected closure"); + assert!( + !snapshot + .object_store() + .join(&extra[..2]) + .join(&extra[2..]) + .exists(), + "should exclude unselected objects" + ); + let retained = snapshot + .object_store() + .join(&revision[..2]) + .join(&revision[2..]); + let before = fs::read(&retained).expect("should read retained commit"); + fs::write( + input.path().join(&revision[..2]).join(&revision[2..]), + b"changed input", + ) + .expect("should replace untrusted input"); + assert_eq!( + fs::read(retained).expect("should read independent retained commit"), + before, + "should own an independent verified snapshot" + ); +} + +#[test] +fn caller_cannot_choose_an_unlocked_selector_or_revision() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let collection = collection(&locked); + assert!( + collection + .verify("git+https://example.com/other", &revision, input.path()) + .is_err(), + "should reject an unlocked selector" + ); + assert!( + collection + .verify(SELECTOR, &"f".repeat(40), input.path()) + .is_err(), + "should reject an unlocked revision" + ); +} + +#[test] +fn locked_revision_must_select_a_commit_object() { + let input = temporary(); + let revision = object(input.path(), "blob", b"not a commit"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject an object of the wrong type" + ); +} + +#[test] +fn changed_blob_bytes_fail_the_selected_object_identity() { + let input = temporary(); + let revision = fixture(input.path()); + let blob = object(input.path(), "blob", b"fictional dependency\n"); + let different = object(input.path(), "blob", b"changed dependency\n"); + fs::copy( + input.path().join(&different[..2]).join(&different[2..]), + input.path().join(&blob[..2]).join(&blob[2..]), + ) + .expect("should replace blob bytes under old name"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should rehash blob content independently of its filename" + ); +} + +#[test] +fn unsafe_entry_outside_cargo_package_root_still_rejects() { + let input = temporary(); + let blob = object(input.path(), "blob", b"fictional dependency"); + let unrelated = tree(input.path(), &[(b"160000", b"unreviewed-submodule", &blob)]); + let root = tree( + input.path(), + &[ + (b"100644", b"Cargo.toml", &blob), + (b"40000", b"unrelated", &unrelated), + ], + ); + let revision = commit(input.path(), &root); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should walk every selected subtree" + ); +} + +#[test] +fn missing_blob_in_unrelated_subtree_still_rejects() { + let input = temporary(); + let blob = object(input.path(), "blob", b"fictional dependency"); + let unrelated = tree(input.path(), &[(b"100644", b"missing", &"d".repeat(40))]); + let root = tree( + input.path(), + &[ + (b"100644", b"Cargo.toml", &blob), + (b"40000", b"unrelated", &unrelated), + ], + ); + let revision = commit(input.path(), &root); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should authenticate blobs outside selected crate roots" + ); +} + +#[test] +fn unsafe_or_noncanonical_tree_entries_reject() { + let oid = "d".repeat(40); + for (mode, name) in [ + (&b"160000"[..], &b"submodule"[..]), + (b"100664", b"mode"), + (b"100644", b".."), + (b"100644", b".Git"), + (b"100644", b"back\\slash"), + (b"100644", b"line\nbreak"), + (b"100644", b"a/b"), + (b"100644", b"trailing."), + (b"100644", b"trailing "), + (b"100644", b"colon:name"), + (b"100644", b"\xc3\xa9"), + ] { + let mut bytes = mode.to_vec(); + bytes.push(b' '); + bytes.extend_from_slice(name); + bytes.push(0); + bytes.extend(hex::decode(&oid).expect("should decode fixture id")); + assert!( + parse_tree(&bytes).is_err(), + "should reject unsafe tree entry" + ); + } + for names in [["A", "a"], ["a", "a"], ["z", "a"]] { + let mut bytes = Vec::new(); + for name in names { + bytes.extend_from_slice(format!("100644 {name}\0").as_bytes()); + bytes.extend(hex::decode(&oid).expect("should decode fixture id")); + } + assert!( + parse_tree(&bytes).is_err(), + "should reject aliases, duplicate names and noncanonical ordering" + ); + } +} + +#[test] +fn linked_blobs_are_retained_as_inert_verified_objects() { + let input = temporary(); + let target = object(input.path(), "blob", b"example readme bytes\n"); + let link = object(input.path(), "blob", b"../README.md"); + let package = tree(input.path(), &[(b"120000", b"README.md", &link)]); + let root = tree( + input.path(), + &[ + (b"100644", b"README.md", &target), + (b"40000", b"package", &package), + ], + ); + let revision = commit(input.path(), &root); + let locked = locked(&revision); + let snapshot = collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .expect("should retain authenticated link blobs without creating links"); + assert_eq!( + snapshot.object_count(), + 5, + "should retain the complete commit closure" + ); + assert!( + snapshot + .object_store() + .join(&link[..2]) + .join(&link[2..]) + .is_file(), + "should retain the link target as an inert object" + ); +} + +#[test] +fn input_configuration_and_alternates_never_supply_objects() { + let input = temporary(); + let revision = fixture(input.path()); + fs::create_dir(input.path().join("info")).expect("should create object info fixture"); + fs::write( + input.path().join("info/alternates"), + b"/fictional/untrusted\n", + ) + .expect("should write forbidden indirection"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject object-store indirection" + ); + fs::remove_file(input.path().join("info/alternates")) + .expect("should remove forbidden indirection"); + fs::write( + input.path().join("config"), + b"[core]\nrepositoryformatversion=999\n", + ) + .expect("should write forbidden configuration"); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject configuration inside object input" + ); +} + +#[test] +fn input_ancestor_symlink_and_object_hardlink_are_rejected() { + let parent = temporary(); + let input = parent.path().join("objects"); + fs::create_dir(&input).expect("should create object root"); + let revision = fixture(&input); + let locked = locked(&revision); + let alias = parent.path().join("alias"); + std::os::unix::fs::symlink(&input, &alias).expect("should create ancestor alias"); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, &alias) + .is_err(), + "should reject aliased input ancestors" + ); + let original = input.join(&revision[..2]).join(&revision[2..]); + fs::hard_link(&original, parent.path().join("hardlink")) + .expect("should create outside hardlink"); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, &input) + .is_err(), + "should reject hard-linked input files" + ); +} + +#[test] +fn cancelled_and_expired_collections_fail_closed() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let cancellation = Cancellation::default(); + let collection = locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() + Duration::from_secs(60), + &cancellation, + ) + .expect("should create live collection"); + cancellation.cancel(); + assert!( + collection + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject cancelled work" + ); + assert!( + locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() - Duration::from_secs(1), + &Cancellation::default() + ) + .is_err(), + "should reject an expired collection" + ); +} + +fn pack(input: &Path, revision: &str) { + let repository = input.parent().expect("should have repository parent"); + fs::create_dir(repository.join("refs")).expect("should create bare refs"); + fs::write(repository.join("HEAD"), b"ref: refs/heads/unused\n") + .expect("should write bare HEAD"); + fs::write( + repository.join("config"), + b"[core]\nbare=true\nrepositoryformatversion=0\n", + ) + .expect("should write bare config"); + fs::create_dir(input.join("pack")).expect("should create pack directory"); + let output = BoundedCommand::new( + Path::new("/usr/bin/git"), + repository, + ProcessLimits { + wall: Duration::from_secs(30), + stdout_max: 1024, + stderr_max: 4096, + }, + ) + .expect("should describe real Git fixture") + .arguments([ + OsString::from("--git-dir"), + repository.as_os_str().to_owned(), + OsString::from("pack-objects"), + OsString::from("--revs"), + input.join("pack/pack").into_os_string(), + ]) + .environment("HOME", repository) + .expect("should pin HOME") + .environment("GIT_CONFIG_NOSYSTEM", "1") + .expect("should disable system config") + .stdin_bytes(format!("{revision}\n").into_bytes()) + .run() + .and_then(ProcessResult::require_success) + .expect("should create real Git pack and index"); + assert_eq!(output.len(), 41, "should emit one pack identity"); + for entry in fs::read_dir(input).expect("should read fixture object root") { + let entry = entry.expect("should read object entry"); + if entry.file_name().len() == 2 { + fs::remove_dir_all(entry.path()).expect("should remove loose originals"); + } + } +} + +#[test] +fn real_packed_objects_are_verified_and_reemitted_as_selected_loose_objects() { + let parent = temporary(); + let input = parent.path().join("objects"); + fs::create_dir(&input).expect("should create object root"); + let revision = fixture(&input); + pack(&input, &revision); + let locked = locked(&revision); + let snapshot = collection(&locked) + .verify(SELECTOR, &revision, &input) + .expect("should independently verify real packed objects"); + assert_eq!( + snapshot.object_count(), + 3, + "should retain all selected objects" + ); + assert!( + !snapshot.object_store().join("pack").exists(), + "should not retain incoming pack extras" + ); +} + +#[test] +fn malformed_packed_material_fails_closed() { + let parent = temporary(); + let input = parent.path().join("objects"); + fs::create_dir(&input).expect("should create object root"); + let revision = fixture(&input); + pack(&input, &revision); + let index = fs::read_dir(input.join("pack")) + .expect("should list packs") + .map(|entry| entry.expect("should read pack entry").path()) + .find(|path| path.extension().is_some_and(|extension| extension == "idx")) + .expect("should have pack index"); + fs::remove_file(&index).expect("should remove read-only fixture index"); + fs::write(index, b"truncated index").expect("should corrupt pack index"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, &input) + .is_err(), + "should reject malformed packed material" + ); +} + +#[test] +fn decoded_budget_is_shared_across_separate_dependency_verifications() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let collection = collection(&locked); + collection + .verify(SELECTOR, &revision, input.path()) + .expect("should verify first snapshot"); + collection.objects.set_budget_for_tests(1); + assert!( + collection + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should preserve collection budget instead of resetting per repository" + ); +} + +#[test] +fn object_symlink_and_fifo_are_rejected_without_blocking() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let path = input.path().join(&revision[..2]).join(&revision[2..]); + fs::remove_file(&path).expect("should remove selected loose object"); + std::os::unix::fs::symlink("/fictional/untrusted", &path) + .expect("should create dangling object alias"); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject symlink input objects" + ); + fs::remove_file(&path).expect("should remove object alias"); + let raw = std::ffi::CString::new(std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str())) + .expect("should encode FIFO path"); + // The NUL-terminated path stays alive while mkfifo creates a test-only inode. + assert_eq!( + unsafe { libc::mkfifo(raw.as_ptr(), 0o600) }, + 0, + "should create FIFO fixture" + ); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject FIFO objects without waiting for a writer" + ); +} + +#[test] +fn changed_selected_git_is_rejected_before_execution() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let tool_root = temporary(); + let program = tool_root.path().join("git"); + fs::copy("/usr/bin/git", &program).expect("should copy selected executable fixture"); + let collection = locked + .git_collection( + &program, + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should select fixture executable"); + fs::write(&program, b"changed selected executable").expect("should mutate executable fixture"); + assert!( + collection + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should rejoin selected executable bytes before spawning" + ); +} + +#[test] +fn deeply_nested_selected_tree_rejects_under_depth_bound() { + let input = temporary(); + let mut root = object(input.path(), "tree", b""); + for _ in 0..TREE_DEPTH { + root = tree(input.path(), &[(b"40000", b"d", &root)]); + } + let revision = commit(input.path(), &root); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject a selected tree deeper than the fixed bound" + ); +} + +#[test] +fn truncated_pack_body_is_rejected() { + let parent = temporary(); + let input = parent.path().join("objects"); + fs::create_dir(&input).expect("should create object root"); + let revision = fixture(&input); + pack(&input, &revision); + let body = fs::read_dir(input.join("pack")) + .expect("should list packs") + .map(|entry| entry.expect("should read pack entry").path()) + .find(|path| { + path.extension() + .is_some_and(|extension| extension == "pack") + }) + .expect("should have pack body"); + fs::remove_file(&body).expect("should remove read-only fixture pack"); + fs::write(body, b"PACK").expect("should truncate pack body"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, &input) + .is_err(), + "should reject truncated packed material" + ); +} + +#[test] +fn duplicate_root_tree_header_is_not_accepted() { + let input = temporary(); + let tree = object(input.path(), "tree", b""); + let revision = object( + input.path(), + "commit", + format!("tree {tree}\ntree {tree}\n\nfixture\n").as_bytes(), + ); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject an ambiguous root-tree binding" + ); +} + +#[test] +fn fifo_replacement_of_selected_git_does_not_wait_for_a_writer() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let tools = temporary(); + let program = tools.path().join("git"); + fs::copy("/usr/bin/git", &program).expect("should copy executable fixture"); + let collection = locked + .git_collection( + &program, + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should select copied executable"); + fs::remove_file(&program).expect("should remove selected executable"); + let raw = std::ffi::CString::new(std::os::unix::ffi::OsStrExt::as_bytes(program.as_os_str())) + .expect("should encode FIFO path"); + // The test replaces an owned executable path with a test-only FIFO. + assert_eq!( + unsafe { libc::mkfifo(raw.as_ptr(), 0o700) }, + 0, + "should create FIFO replacement" + ); + std::thread::scope(|scope| { + let (sender, receiver) = std::sync::mpsc::channel(); + let worker = scope.spawn(move || { + let rejected = collection + .verify(SELECTOR, &revision, input.path()) + .is_err(); + sender + .send(rejected) + .expect("should report the FIFO verification outcome"); + }); + let outcome = receiver.recv_timeout(Duration::from_secs(10)); + // A read/write nonblocking descriptor also releases a regressed reader + // that reaches open after the timeout, without relying on timer ordering. + let rescued = if outcome.is_err() { + Some( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(&program) + .expect("should release a regressed blocking FIFO reader"), + ) + } else { + None + }; + worker + .join() + .expect("should join the FIFO verification worker"); + let rejected = outcome + .unwrap_or_else(|_| receiver.recv().expect("should receive the rescued outcome")); + assert!(rejected, "should reject FIFO executable replacement"); + assert!( + rescued.is_none(), + "should reject without requiring a FIFO writer" + ); + }); +} + +fn independent_object_copy(input: &Path, output: &Path) { + let mut total = 0_u64; + let mut count = 0_usize; + for entry in fs::read_dir(input).expect("should list original object data") { + let entry = entry.expect("should read original object directory"); + assert!( + fs::symlink_metadata(entry.path()) + .expect("should inspect original object directory") + .is_dir(), + "should copy object directories only" + ); + let name = entry.file_name(); + let name = name.to_str().expect("should use canonical object names"); + assert!( + name == "pack" + || name == "info" + || super::lower_oid(format!("{name}{}", "0".repeat(38)).as_bytes()), + "should reject unrelated object-store entries" + ); + fs::create_dir(output.join(name)).expect("should create independent object directory"); + for member in fs::read_dir(entry.path()).expect("should list object directory") { + let member = member.expect("should read original object member"); + let metadata = + fs::symlink_metadata(member.path()).expect("should inspect original object member"); + assert!(metadata.is_file(), "should not follow original aliases"); + total += metadata.len(); + count += 1; + assert!( + total <= 512 * 1024 * 1024 && count <= 100_000, + "should bound diagnostic input copying before original verification" + ); + let destination = output.join(name).join(member.file_name()); + fs::copy(member.path(), &destination).expect("should independently copy object bytes"); + assert_eq!( + fs::metadata(destination) + .expect("should inspect private copy") + .nlink(), + 1, + "should detach ambient cache hard links" + ); + } + } +} + +#[test] +#[ignore = "requires explicitly selected current-lock Git object data; no acquisition qualification"] +fn actual_current_lock_git_material_closure() { + let input = std::env::var_os("TS_RELEASE_CARGO_GIT_FIXTURE") + .map(PathBuf::from) + .expect("should explicitly select original Git object data"); + let bytes = fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.lock")) + .expect("should read the actual current source lockfile"); + let cargo = crate::materials::locked::parse_cargo(&bytes) + .expect("should parse all current locked identities"); + assert_eq!( + cargo.git.len(), + 1, + "should explicitly review the current unique Git material" + ); + let (selector, revision) = cargo + .git + .iter() + .next() + .expect("should select locked Git material") + .clone(); + let locked = LockedMaterials { + // This diagnostic fixture supplies no official source approval. + source_commit: "a".repeat(40), + cargo_lock_sha256: hex::encode(Sha256::digest(&bytes)), + npm_lock_sha256: "b".repeat(64), + cargo, + npm: BTreeMap::new(), + }; + let copied = temporary(); + independent_object_copy(&input, copied.path()); + let snapshot = locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() + Duration::from_secs(180), + &Cancellation::default(), + ) + .expect("should select bounded local diagnostic Git") + .verify(&selector, &revision, copied.path()) + .expect("should verify the actual current locked whole-tree material"); + assert_eq!( + snapshot.revision(), + revision, + "should retain the exact current locked revision" + ); + assert_eq!( + snapshot.selector(), + selector, + "should retain the exact current locked selector" + ); + assert_eq!( + snapshot.object_count(), + 557, + "should cover the independently measured current whole closure" + ); + assert_eq!( + snapshot.cargo_lock_sha256(), + hex::encode(Sha256::digest(&bytes)), + "should bind exact current raw lock bytes" + ); + let mut total = 0_u64; + let mut largest = 0_u64; + let mut blob_path = None; + for directory in + fs::read_dir(snapshot.object_store()).expect("should inspect retained object directories") + { + for member in fs::read_dir(directory.expect("should read retained directory").path()) + .expect("should inspect retained objects") + { + let path = member.expect("should read retained object").path(); + let decoder = flate2::read::ZlibDecoder::new( + fs::File::open(&path).expect("should open verified loose object"), + ); + let mut decoded = Vec::new(); + decoder + .take(32 * 1024 * 1024 + 128) + .read_to_end(&mut decoded) + .expect("should decode bounded verified loose object"); + let end = decoded + .iter() + .position(|byte| *byte == 0) + .expect("should retain canonical object header"); + let size = (decoded.len() - end - 1) as u64; + assert!( + size <= 32 * 1024 * 1024, + "should preserve the Cargo per-object bound" + ); + total += size; + largest = largest.max(size); + if decoded.starts_with(b"blob ") { + blob_path = Some(path); + } + } + } + assert_eq!( + total, 6_334_558, + "should retain exactly the independently measured decoded closure bytes" + ); + assert_eq!( + largest, 636_919, + "should retain the independently measured largest object" + ); + assert!( + total <= 512 * 1024 * 1024, + "should remain within the shared Cargo budget" + ); + let altered = temporary(); + independent_object_copy(snapshot.object_store(), altered.path()); + let blob = blob_path.expect("should retain at least one real blob"); + let relative = blob + .strip_prefix(snapshot.object_store()) + .expect("should select a private retained object"); + fs::write(altered.path().join(relative), b"corrupt object") + .expect("should corrupt only independently copied data"); + assert!( + collection(&locked) + .verify(&selector, &revision, altered.path()) + .is_err(), + "should reject changed actual selected object bytes" + ); + let root = snapshot.root_tree(); + fs::remove_file(altered.path().join(&root[..2]).join(&root[2..])) + .expect("should remove the actual selected root tree from private data"); + assert!( + collection(&locked) + .verify(&selector, &revision, altered.path()) + .is_err(), + "should reject missing actual selected root tree" + ); +} diff --git a/crates/trusted-server-release/src/materials/locked.rs b/crates/trusted-server-release/src/materials/locked.rs new file mode 100644 index 000000000..fd78954f8 --- /dev/null +++ b/crates/trusted-server-release/src/materials/locked.rs @@ -0,0 +1,262 @@ +//! Parses identities without granting acquisition or execution authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use error_stack::Report; +use serde_json::Value; + +use super::material_error; +use crate::documents::{Role, lower_hex, parse_json}; +use crate::error::ReleaseError; + +// This runtime acquisition identity must match the approved lockfile registry. +pub(super) const NPM_REGISTRY: &str = "https://registry.npmjs.org/"; // allow-domain: registry.npmjs.org +pub(super) const CARGO_REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index"; + +/// One original npm distribution, retaining its installation-path join. +pub(super) struct NpmMaterial { + pub(super) name: String, + pub(super) version: String, + pub(super) resolved: String, + pub(super) sha512: [u8; 64], +} + +/// One registry crate's exact original distribution identity. +pub(super) struct RegistryMaterial { + pub(super) name: String, + pub(super) version: String, + pub(super) sha256: String, +} + +/// One Git package's exact locked identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct GitPackage { + pub(super) name: String, + pub(super) version: String, + /// Lock source without its `#` suffix, for example + /// `git+https://example.com/org/repo?tag=v1`. + pub(super) selector: String, + pub(super) revision: String, +} + +/// Cargo acquisition expectations, not verified dependency trees. +pub(super) struct CargoMaterials { + pub(super) registry: BTreeMap<(String, String), RegistryMaterial>, + pub(super) git: BTreeSet<(String, String)>, + pub(super) git_packages: Vec, +} + +fn text<'a>(value: &'a Value, field: &str) -> Result<&'a str, Report> { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| material_error(format!("locked npm package has no {field}"))) +} + +fn integrity(value: &str) -> Result<[u8; 64], Report> { + let encoded = value + .strip_prefix("sha512-") + .ok_or_else(|| material_error("npm archive requires one SHA-512 integrity"))?; + let bytes = STANDARD.decode(encoded).map_err(material_error)?; + if STANDARD.encode(&bytes) != encoded { + return Err(material_error("npm integrity is not canonical base64")); + } + bytes + .try_into() + .map_err(|_| material_error("npm SHA-512 identity must have 64 bytes")) +} + +fn component(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn npm_name(name: &str) -> bool { + let parts: Vec<&str> = name.split('/').collect(); + match parts.as_slice() { + [name] => component(name), + [scope, name] => scope.strip_prefix('@').is_some_and(component) && component(name), + _ => false, + } +} + +pub(super) fn parse_npm( + bytes: &[u8], +) -> Result, Report> { + let document = parse_json(bytes, Role::Metadata)?; + if document.get("lockfileVersion").and_then(Value::as_u64) != Some(3) { + return Err(material_error("npm lockfile must use version 3")); + } + let packages = document + .get("packages") + .and_then(Value::as_object) + .ok_or_else(|| material_error("npm lockfile has no package table"))?; + let mut result = BTreeMap::new(); + for (path, package) in packages { + if path.is_empty() { + continue; + } + if !path.starts_with("node_modules/") + || path.contains('\\') + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || path.chars().any(char::is_control) + { + return Err(material_error( + "npm installation path is not a safe locked location", + )); + } + let locations = path + .strip_prefix("node_modules/") + .ok_or_else(|| material_error("missing locked installation prefix"))?; + if locations + .split("/node_modules/") + .any(|name| !npm_name(name)) + { + return Err(material_error("invalid nested npm installation location")); + } + let inferred = path + .rsplit("node_modules/") + .next() + .ok_or_else(|| material_error("missing npm package name"))?; + let name = package + .get("name") + .map(|value| { + value + .as_str() + .ok_or_else(|| material_error("locked npm name must be a string")) + }) + .transpose()? + .unwrap_or(inferred); + if name != inferred { + return Err(material_error( + "npm aliases are outside the supported material contract", + )); + } + let linked = package + .get("link") + .map(|value| { + value + .as_bool() + .ok_or_else(|| material_error("locked npm link selector must be a boolean")) + }) + .transpose()? + .unwrap_or(false); + let version = text(package, "version")?; + if !npm_name(name) + || !version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+')) + { + return Err(material_error( + "npm identity contains unsupported path or version characters", + )); + } + let resolved = text(package, "resolved")?; + let basename = name + .rsplit('/') + .next() + .ok_or_else(|| material_error("invalid npm package name"))?; + let expected = format!("{NPM_REGISTRY}{name}/-/{basename}-{version}.tgz"); + if resolved != expected || linked { + return Err(material_error( + "npm distribution is not its canonical registry archive", + )); + } + result.insert( + path.clone(), + NpmMaterial { + name: name.to_owned(), + version: version.to_owned(), + resolved: resolved.to_owned(), + sha512: integrity(text(package, "integrity")?)?, + }, + ); + } + Ok(result) +} + +pub(super) fn parse_cargo(bytes: &[u8]) -> Result> { + let text = std::str::from_utf8(bytes).map_err(material_error)?; + let lock: toml::Value = toml::from_str(text).map_err(material_error)?; + if lock.get("version").and_then(toml::Value::as_integer) != Some(4) { + return Err(material_error("Cargo lockfile must use version 4")); + } + let packages = lock + .get("package") + .and_then(toml::Value::as_array) + .ok_or_else(|| material_error("Cargo lockfile has no package array"))?; + let mut result = CargoMaterials { + registry: BTreeMap::new(), + git: BTreeSet::new(), + git_packages: Vec::new(), + }; + let mut identities = BTreeSet::new(); + for package in packages { + let field = |key| { + package + .get(key) + .and_then(toml::Value::as_str) + .ok_or_else(|| material_error(format!("Cargo package has no {key}"))) + }; + let name = field("name")?; + let version = field("version")?; + let source = package + .get("source") + .map(|value| { + value + .as_str() + .ok_or_else(|| material_error("Cargo source must be a string")) + }) + .transpose()?; + if !identities.insert((name, version, source)) { + return Err(material_error("duplicate Cargo package identity")); + } + match source { + None => {} + Some(CARGO_REGISTRY) => { + let checksum = field("checksum")?; + if !lower_hex(checksum, 64) { + return Err(material_error("registry archive has no exact SHA-256")); + } + result.registry.insert( + (name.to_owned(), version.to_owned()), + RegistryMaterial { + name: name.to_owned(), + version: version.to_owned(), + sha256: checksum.to_owned(), + }, + ); + } + Some(source) if source.starts_with("git+https://") => { + let (repository, commit) = source + .rsplit_once('#') + .ok_or_else(|| material_error("Git material lacks a locked revision"))?; + if !lower_hex(commit, 40) || repository.chars().any(char::is_control) { + return Err(material_error( + "Git material requires a full lowercase revision", + )); + } + result + .git + .insert((repository.to_owned(), commit.to_owned())); + result.git_packages.push(GitPackage { + name: name.to_owned(), + version: version.to_owned(), + selector: repository.to_owned(), + revision: commit.to_owned(), + }); + } + Some(_) => return Err(material_error("unsupported Cargo material source")), + } + } + Ok(result) +} diff --git a/crates/trusted-server-release/src/materials/manifest.rs b/crates/trusted-server-release/src/materials/manifest.rs new file mode 100644 index 000000000..37bd71732 --- /dev/null +++ b/crates/trusted-server-release/src/materials/manifest.rs @@ -0,0 +1,391 @@ +//! Reviews declarations only; this module never observes or executes hooks. + +use error_stack::Report; +use serde_json::{Map, Value}; +use sha2::{Digest as _, Sha256}; + +use super::{ExtractedOriginal, material_error}; +use crate::documents::{Role, parse_json}; +use crate::error::ReleaseError; +use crate::source::ApprovedSource; + +const ROOT_MANIFEST: &str = "crates/trusted-server-js/lib/package.json"; +const ROOT_BINDING: &str = "crates/trusted-server-js/lib/binding.gyp"; +const ROOT_EVENTS: [&str; 10] = [ + "preinstall", + "install", + "postinstall", + "prepublish", + "preprepare", + "prepare", + "postprepare", + "predependencies", + "dependencies", + "postdependencies", +]; + +// These exact declarations are source-owned policy, separate from observations. +const REVIEWED_HOOKS: [(&str, &str, &str, &str); 2] = [ + ( + "core-js", + "3.48.0", + "postinstall", + "node -e \"try{require('./postinstall')}catch(e){}\"", + ), + ("esbuild", "0.27.3", "postinstall", "node install.js"), +]; + +/// Approved root manifest declarations, without lifecycle execution authority. +pub(crate) struct RootManifestReview { + source_commit: String, + manifest_sha256: String, +} + +impl RootManifestReview { + /// Returns the approved source identity supplying the reviewed declarations. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } +} + +/// A declared install hook; this is not an observed lifecycle event. +pub(crate) struct DeclaredHook { + event: String, + script_sha256: String, +} + +impl DeclaredHook { + /// Returns the reviewed npm event name. + pub(crate) fn event(&self) -> &str { + &self.event + } + + /// Returns the hash of exact UTF-8 command bytes from the verified manifest. + pub(crate) fn script_sha256(&self) -> &str { + &self.script_sha256 + } +} + +/// Reviewed declarations from one retained, authenticated npm original. +/// +/// This type cannot construct a lifecycle observation or completed build. +pub(crate) struct ReviewedNpmManifest { + extracted: ExtractedOriginal, + manifest_sha256: String, + hooks: Vec, +} + +impl ReviewedNpmManifest { + /// Returns exact original member facts after rechecking retained extraction. + /// + /// # Errors + /// + /// Returns an error for a stale extraction or absent member. + pub(super) fn member_facts( + &self, + relative: &str, + ) -> Result<(String, u64, u32), Report> { + self.extracted.member_facts(relative) + } + /// Rechecks all archive members before preparation consumes this review. + /// + /// # Errors + /// + /// Returns an error if the retained extraction changed or expired. + pub(crate) fn verify_unchanged(&self) -> Result<(), Report> { + self.extracted.verify_unchanged() + } + + /// Copies the authenticated compressed original for a new private cache. + /// + /// # Errors + /// + /// Returns an error if retained original bytes changed or copying fails. + pub(super) fn copy_original( + &mut self, + destination: &mut impl std::io::Write, + ) -> Result<(), Report> { + self.extracted.copy_original(destination) + } + /// Returns declarations only, without claiming any hook was executed. + pub(crate) fn hooks(&self) -> &[DeclaredHook] { + &self.hooks + } +} + +/// Reviews only approved tracked root declarations. +/// +/// # Errors +/// +/// Returns an error for changed source, malformed manifests or root lifecycle hooks. +pub(super) fn review_root( + source: &ApprovedSource, +) -> Result> { + source.snapshot().verify_unchanged()?; + let bytes = source.read_tracked(ROOT_MANIFEST, Role::Metadata)?; + let document = parse_json(&bytes, Role::Metadata)?; + review_root_document(&document, source.tracked_sha256(ROOT_BINDING).is_some())?; + source.snapshot().verify_unchanged()?; + Ok(RootManifestReview { + source_commit: source.commit().as_str().to_owned(), + manifest_sha256: hex::encode(Sha256::digest(&bytes)), + }) +} + +/// Reviews exact dependency declarations from authenticated archive members. +/// +/// # Errors +/// +/// Returns an error for changed members, identity substitutions or unreviewed hooks. +pub(super) fn review_npm( + extracted: ExtractedOriginal, + name: &str, + version: &str, +) -> Result> { + let bytes = extracted.read_member_checked("package.json")?; + let document = parse_json(&bytes, Role::Metadata)?; + let binding = extracted.contains_member_checked("binding.gyp")?; + let hooks = declared_hooks(&document, name, version, binding)?; + Ok(ReviewedNpmManifest { + extracted, + manifest_sha256: hex::encode(Sha256::digest(&bytes)), + hooks, + }) +} + +fn scripts(document: &Value) -> Result>, Report> { + let object = document + .as_object() + .ok_or_else(|| material_error("package manifest must be an object"))?; + match object.get("scripts") { + None => Ok(None), + Some(Value::Object(scripts)) if scripts.values().all(Value::is_string) => Ok(Some(scripts)), + Some(_) => Err(material_error( + "manifest scripts must contain command strings", + )), + } +} + +fn review_root_document(document: &Value, binding: bool) -> Result<(), Report> { + let scripts = scripts(document)?; + if binding + || ROOT_EVENTS + .iter() + .any(|event| scripts.is_some_and(|scripts| scripts.contains_key(*event))) + { + return Err(material_error( + "root npm ci lifecycle hooks and implicit installs are not allowed", + )); + } + Ok(()) +} + +fn declared_hooks( + document: &Value, + name: &str, + version: &str, + binding: bool, +) -> Result, Report> { + if document.get("name").and_then(Value::as_str) != Some(name) + || document.get("version").and_then(Value::as_str) != Some(version) + { + return Err(material_error( + "verified package manifest differs from locked package identity", + )); + } + let scripts = scripts(document)?; + if binding + && !scripts.is_some_and(|scripts| { + scripts.contains_key("preinstall") || scripts.contains_key("install") + }) + { + return Err(material_error( + "implicit binding.gyp installs are not reviewed", + )); + } + let mut result = Vec::new(); + for event in ["preinstall", "install", "postinstall"] { + let Some(command) = scripts + .and_then(|scripts| scripts.get(event)) + .and_then(Value::as_str) + else { + continue; + }; + if !REVIEWED_HOOKS.contains(&(name, version, event, command)) { + return Err(material_error( + "dependency install hook has no exact source-owned review", + )); + } + result.push(DeclaredHook { + event: event.to_owned(), + script_sha256: hex::encode(Sha256::digest(command.as_bytes())), + }); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn empty_development_script_does_not_authorize_a_lifecycle_hook() { + let mut manifest = + json!({"name":"fictional-package","version":"1.0.0","scripts":{"test":""}}); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", false) + .expect("should accept an unused empty development script") + .is_empty(), + "should declare no install execution from a development script" + ); + assert!( + review_root_document(&manifest, false).is_ok(), + "should allow a root development script without lifecycle declarations" + ); + manifest["scripts"]["postinstall"] = json!(""); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", false).is_err(), + "should still reject an unreviewed empty lifecycle declaration" + ); + assert!( + review_root_document(&manifest, false).is_err(), + "should still reject root lifecycle declarations" + ); + } + + #[test] + fn root_review_allows_build_scripts_but_rejects_all_ci_hooks() { + let mut manifest = json!({"name":"fictional-project", "scripts":{"build":"node build.mjs", "test":"vitest run"}}); + assert!( + review_root_document(&manifest, false).is_ok(), + "should allow ordinary explicit build and test scripts" + ); + assert!( + review_root_document(&manifest, true).is_err(), + "should reject implicit root binding install" + ); + for event in [ + "preinstall", + "install", + "postinstall", + "prepublish", + "preprepare", + "prepare", + "postprepare", + "predependencies", + "dependencies", + "postdependencies", + ] { + manifest["scripts"][event] = json!("node fictional-hook.mjs"); + assert!( + review_root_document(&manifest, false).is_err(), + "should reject root lifecycle hook" + ); + manifest["scripts"] + .as_object_mut() + .expect("should have scripts object") + .remove(event); + } + } + + #[test] + fn dependency_review_requires_exact_source_owned_hook_tuples() { + let manifest = json!({"name":"fictional-package", "version":"1.0.0"}); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", false) + .expect("should accept package without hooks") + .is_empty(), + "should declare no hook execution" + ); + assert!( + declared_hooks(&manifest, "other-package", "1.0.0", false).is_err(), + "should reject manifest identity substitution" + ); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", true).is_err(), + "should reject implicit binding install" + ); + for (name, version, command) in [ + ( + "core-js", + "3.48.0", + "node -e \"try{require('./postinstall')}catch(e){}\"", + ), + ("esbuild", "0.27.3", "node install.js"), + ] { + let mut manifest = + json!({"name":name,"version":version,"scripts":{"postinstall":command}}); + assert_eq!( + declared_hooks(&manifest, name, version, false) + .expect("should match exact reviewed declaration") + .len(), + 1, + "should return one declaration" + ); + manifest["scripts"]["postinstall"] = json!(format!("{command} ")); + assert!( + declared_hooks(&manifest, name, version, false).is_err(), + "should reject changed command bytes" + ); + manifest["scripts"]["postinstall"] = json!(command); + manifest["scripts"]["install"] = json!("node other.mjs"); + assert!( + declared_hooks(&manifest, name, version, false).is_err(), + "should reject added lifecycle command" + ); + } + } + + #[test] + fn rejects_malformed_scripts_and_hashes_the_exact_declared_command() { + for scripts in [ + Value::Null, + json!([]), + json!({"postinstall":42}), + json!({"postinstall":""}), + ] { + let manifest = + json!({"name":"fictional-package", "version":"1.0.0", "scripts":scripts}); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", false).is_err(), + "should reject malformed script declarations" + ); + } + let manifest = json!({"name":"esbuild", "version":"0.27.3", "scripts":{"postinstall":"node install.js"}}); + let declarations = declared_hooks(&manifest, "esbuild", "0.27.3", false) + .expect("should match reviewed command"); + assert_eq!( + declarations[0].event(), + "postinstall", + "should preserve declared event" + ); + assert_eq!( + declarations[0].script_sha256(), + "912d4d8f507b7b392ae422a459f98da94669a742e4cc43cfe061e630ee2846fe", + "should hash exact UTF-8 command bytes" + ); + let mut changed = manifest; + changed["version"] = json!("0.27.4"); + assert!( + declared_hooks(&changed, "esbuild", "0.27.4", false).is_err(), + "should reject unreviewed locked version" + ); + let prepare = json!({"name":"fictional-package", "version":"1.0.0", "scripts":{"prepare":"node source-only.mjs"}}); + assert!( + declared_hooks(&prepare, "fictional-package", "1.0.0", false) + .expect("should parse registry manifest") + .is_empty(), + "should not infer registry prepare execution" + ); + assert!( + parse_json( + br#"{"name":"fictional-package","name":"substitute","version":"1.0.0"}"#, + Role::Metadata + ) + .is_err(), + "should reject duplicate manifest keys before selection" + ); + } +} diff --git a/crates/trusted-server-release/src/materials/npm_bundle.rs b/crates/trusted-server-release/src/materials/npm_bundle.rs new file mode 100644 index 000000000..d76575079 --- /dev/null +++ b/crates/trusted-server-release/src/materials/npm_bundle.rs @@ -0,0 +1,314 @@ +//! Original-only inputs shared read-only by fresh npm installations. + +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use error_stack::Report; +use tempfile::TempDir; + +use super::{LockedMaterials, ReviewedNpmManifest, material_error}; +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; +use crate::process::Cancellation; + +/// Retained originals and manifest reviews, without installed build authority. +pub(crate) struct PreparedNpmOriginals { + directory: TempDir, + originals: BTreeMap, + reviews: BTreeMap, + source_commit: String, + lock_sha256: String, + deadline: Instant, + cancellation: Cancellation, +} + +impl PreparedNpmOriginals { + /// Measures the selected Linux esbuild executable from its original archive. + /// + /// # Errors + /// + /// Returns an error for unsupported architectures, absent originals or a + /// stale extraction. These are original facts, not installed observations. + pub(crate) fn native_esbuild( + &self, + architecture: &str, + ) -> Result> { + let architecture = match architecture { + "x86_64" => "x64", + "aarch64" => "arm64", + other => other, + }; + if !matches!(architecture, "x64" | "arm64") { + return Err(material_error( + "unsupported Linux JS preparation architecture", + )); + } + let location = format!("node_modules/@esbuild/linux-{architecture}"); + let review = self + .reviews + .get(&location) + .ok_or_else(|| material_error("required native esbuild original is absent"))?; + let (sha256, size, mode) = review.member_facts("bin/esbuild")?; + Ok(serde_json::json!({"lock_path":location,"sha256":sha256,"size":size,"mode":mode})) + } + /// Returns exact reviewed declarations for joining real lifecycle events. + pub(crate) fn declared_hooks(&self) -> BTreeMap<(String, String), String> { + self.reviews + .iter() + .flat_map(|(location, review)| { + review.hooks().iter().map(|hook| { + ( + (location.clone(), hook.event().to_owned()), + hook.script_sha256().to_owned(), + ) + }) + }) + .collect() + } + /// Returns the private original archive root for a read-only mount. + pub(crate) fn root(&self) -> &Path { + self.directory.path() + } + + /// Returns sorted authenticated archive paths, never extracted replacements. + pub(crate) fn archive_paths(&self) -> Vec { + self.originals + .keys() + .map(|name| self.root().join(name)) + .collect() + } + + /// Returns the approved source and raw lockfile bindings. + pub(crate) fn source_binding(&self) -> (&str, &str) { + (&self.source_commit, &self.lock_sha256) + } + + /// Rechecks every retained original and reviewed extraction before use. + /// + /// # Errors + /// + /// Returns an error for changed originals, stale extractions or expiration. + pub(crate) fn verify_unchanged(&self) -> Result<(), Report> { + let check = || check_attempt(self.deadline, &self.cancellation); + check()?; + for (name, expected) in &self.originals { + let raw = hash_bounded_with_check(&self.root().join(name), 64 * 1024 * 1024, &check)?; + if raw.sha256 != *expected { + return Err(material_error("retained npm original changed")); + } + } + for review in self.reviews.values() { + check()?; + review.verify_unchanged()?; + } + check() + } +} + +impl LockedMaterials { + /// Authenticates and reviews all locked npm originals into private state. + /// + /// # Errors + /// + /// Returns an error for any absent or changed archive, unsafe extraction, + /// unreviewed manifest, canceled attempt or failed private write. Optional + /// platform packages receive the same original checks before npm selects + /// which ones to install. Input filenames come only from locked SHA-512. + pub(crate) fn prepare_npm_originals( + &self, + inputs: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let directory = tempfile::tempdir().map_err(material_error)?; + let mut originals = BTreeMap::new(); + let mut reviews = BTreeMap::new(); + for (location, locked) in &self.npm { + check_attempt(deadline, cancellation)?; + let name = format!("{}.tgz", hex::encode(locked.sha512)); + let verified = self + .verify_npm(location, &inputs.join(&name), deadline, cancellation) + .map_err(|report| { + report.attach(format!( + "authenticating locked npm package `{location}` original `{name}`" + )) + })?; + let expected_sha256 = verified.sha256().to_owned(); + let extracted = self.extract_npm(location, verified).map_err(|report| { + report.attach(format!( + "extracting authenticated locked npm package `{location}`" + )) + })?; + let mut reviewed = self.review_npm(location, extracted).map_err(|report| { + report.attach(format!( + "reviewing authenticated locked npm package `{location}`" + )) + })?; + reviewed.verify_unchanged()?; + if let Entry::Vacant(entry) = originals.entry(name) { + let name = entry.key(); + let path = directory.path().join(name); + let mut file = File::create_new(&path).map_err(material_error)?; + reviewed.copy_original(&mut file)?; + drop(file); + let measured = hash_bounded_with_check(&path, 64 * 1024 * 1024, &|| { + check_attempt(deadline, cancellation) + })?; + if measured.sha256 != expected_sha256 { + return Err(material_error( + "copied npm original differs from authenticated bytes", + )); + } + entry.insert(expected_sha256); + } + reviews.insert(location.to_owned(), reviewed); + } + let prepared = PreparedNpmOriginals { + directory, + originals, + reviews, + source_commit: self.source_commit.clone(), + lock_sha256: self.npm_lock_sha256.clone(), + deadline, + cancellation: cancellation.clone(), + }; + prepared.verify_unchanged()?; + Ok(prepared) + } +} + +fn check_attempt( + deadline: Instant, + cancellation: &Cancellation, +) -> Result<(), Report> { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(material_error( + "npm original collection cancelled or timed out", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::time::Duration; + + use sha2::{Digest as _, Sha256}; + + use super::*; + + fn fixture() -> PreparedNpmOriginals { + let directory = tempfile::tempdir().expect("should create retained originals"); + fs::write(directory.path().join("original.tgz"), b"original") + .expect("should retain fixture original"); + PreparedNpmOriginals { + directory, + originals: BTreeMap::from([( + "original.tgz".to_owned(), + hex::encode(Sha256::digest(b"original")), + )]), + reviews: BTreeMap::new(), + source_commit: "a".repeat(40), + lock_sha256: "b".repeat(64), + deadline: Instant::now() + Duration::from_secs(30), + cancellation: Cancellation::default(), + } + } + + #[test] + fn retained_original_rechecks_obey_attempt_controls() { + let prepared = fixture(); + prepared + .verify_unchanged() + .expect("should hash retained original"); + prepared.cancellation.cancel(); + assert!( + prepared.verify_unchanged().is_err(), + "should reject retained canceled attempts" + ); + let mut prepared = fixture(); + prepared.deadline = Instant::now(); + assert!( + prepared.verify_unchanged().is_err(), + "should reject retained expired attempts" + ); + } + + #[test] + fn retained_original_rechecks_keep_the_archive_bound_and_digest() { + let prepared = fixture(); + let path = prepared.archive_paths().remove(0); + fs::write(&path, b"tampered").expect("should change retained bytes"); + assert!( + prepared.verify_unchanged().is_err(), + "should reject changed retained digest" + ); + fs::File::create(path) + .expect("should create sparse oversized archive") + .set_len(64 * 1024 * 1024 + 1) + .expect("should exceed original archive limit"); + assert!( + prepared.verify_unchanged().is_err(), + "should reject oversized retained archives before allocation" + ); + } + + #[test] + #[ignore = "requires explicitly supplied current-lock original archives; no acquisition qualification"] + fn actual_current_lock_npm_originals() { + let inputs = std::env::var_os("TS_RELEASE_NPM_ORIGINALS") + .map(PathBuf::from) + .expect("should explicitly supply current locked compressed originals"); + let bytes = include_bytes!("../../../trusted-server-js/lib/package-lock.json"); + let npm = crate::materials::locked::parse_npm(bytes) + .expect("should parse every actual current npm lock identity"); + let count = npm.len(); + let archives: std::collections::BTreeSet<_> = + npm.values().map(|entry| entry.sha512).collect(); + assert!(count > 0, "should select nonzero current locked packages"); + let locked = LockedMaterials { + // This original-format diagnostic supplies no official source approval. + source_commit: "a".repeat(40), + cargo_lock_sha256: "b".repeat(64), + npm_lock_sha256: hex::encode(Sha256::digest(bytes)), + cargo: crate::materials::locked::CargoMaterials { + registry: BTreeMap::new(), + git: std::collections::BTreeSet::new(), + git_packages: Vec::new(), + }, + npm, + }; + let prepared = locked + .prepare_npm_originals( + &inputs, + Instant::now() + Duration::from_secs(600), + &Cancellation::default(), + ) + .expect("should authenticate, extract and review every actual current npm original"); + assert_eq!( + prepared.reviews.len(), + count, + "should review every locked package including optional platform entries" + ); + assert_eq!( + prepared.archive_paths().len(), + archives.len(), + "should retain every distinct original compressed distribution" + ); + assert_eq!( + prepared.source_binding(), + ( + "a".repeat(40).as_str(), + hex::encode(Sha256::digest(bytes)).as_str() + ), + "should retain the diagnostic source and exact current raw lock binding" + ); + prepared + .verify_unchanged() + .expect("should rejoin every retained original and complete reviewed extraction"); + } +} diff --git a/crates/trusted-server-release/src/materials/tests.rs b/crates/trusted-server-release/src/materials/tests.rs new file mode 100644 index 000000000..9d71426a4 --- /dev/null +++ b/crates/trusted-server-release/src/materials/tests.rs @@ -0,0 +1,417 @@ +use crate::process::Cancellation; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use flate2::{Compression, write::GzEncoder}; +use serde_json::json; +use sha2::Sha512; +use std::ffi::CString; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::symlink; +use std::time::{Duration, Instant}; + +use super::*; + +fn compatibility_original(root: &str, local_pax: bool) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::fast()); + let mut builder = tar::Builder::new(encoder); + if local_pax { + let mut body = String::new(); + for assignment in ["path=package/file", "size=1", "NODETAR.type=File"] { + let mut length = assignment.len() + 3; + loop { + let record = format!("{length} {assignment}\n"); + if record.len() == length { + body.push_str(&record); + break; + } + length = record.len(); + } + } + let mut header = tar::Header::new_ustar(); + header.set_mode(0o644); + header.set_entry_type(tar::EntryType::XHeader); + header.set_size(body.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "PaxHeader/package/file", body.as_bytes()) + .expect("should append fictional local metadata"); + } + let mut header = tar::Header::new_ustar(); + header.set_mode(0o644); + header.set_size(1); + header.set_cksum(); + builder + .append_data(&mut header, format!("{root}/file"), b"x".as_slice()) + .expect("should append fictional regular member"); + builder + .into_inner() + .expect("should finish fictional TAR") + .finish() + .expect("should finish fictional gzip") +} + +#[test] +fn legacy_root_and_pax_compatibility_require_exact_locked_versions() { + for (name, accepted, rejected, root, pax) in [ + ("@types/chai", "5.2.3", "5.2.4", "chai", false), + ("@types/node", "24.10.13", "24.10.14", "node v24.10", false), + ("gulp-wrap", "0.15.0", "0.15.1", "package", true), + ] { + let bytes = compatibility_original(root, pax); + let directory = tempfile::tempdir().expect("should own fictional distribution"); + let path = directory.path().join("original.tgz"); + std::fs::write(&path, &bytes).expect("should retain fictional original"); + for (version, allowed) in [(accepted, true), (rejected, false)] { + let location = format!("node_modules/{name}"); + let locked = LockedMaterials { + source_commit: "a".repeat(40), + cargo_lock_sha256: "b".repeat(64), + npm_lock_sha256: "c".repeat(64), + cargo: CargoMaterials { + registry: BTreeMap::new(), + git: Default::default(), + git_packages: Vec::new(), + }, + npm: BTreeMap::from([( + location.clone(), + NpmMaterial { + name: name.to_owned(), + version: version.to_owned(), + resolved: "https://example.com/fixture.tgz".to_owned(), + sha512: Sha512::digest(&bytes).into(), + }, + )]), + }; + let original = locked + .verify_npm( + &location, + &path, + Instant::now() + Duration::from_secs(10), + &Cancellation::default(), + ) + .expect("should authenticate exact fictional bytes before selection"); + assert_eq!( + locked.extract_npm(&location, original).is_ok(), + allowed, + "should restrict legacy extraction to reviewed package/version: {name}@{version}" + ); + } + } +} + +fn npm_lock(integrity: &str) -> Vec { + serde_json::to_vec(&json!({ + "lockfileVersion": 3, + "packages": { + "": {"name": "fictional-project"}, + "node_modules/fictional-package": { + "version": "1.0.0", + "resolved": format!("{}fictional-package/-/fictional-package-1.0.0.tgz", locked::NPM_REGISTRY), + "integrity": integrity, + "optional": true + } + } + })).expect("should encode fictional lockfile") +} + +#[test] +fn npm_integrity_requires_one_canonical_sha512_identity() { + for integrity in [ + "", + "sha256-AA==", + "sha512-AA==", + "sha512-???", + "sha512-AA== sha512-AA==", + ] { + assert!( + parse_npm(&npm_lock(integrity)).is_err(), + "should reject missing, ambiguous or unsupported archive integrity" + ); + } +} + +#[test] +fn cargo_git_revision_cannot_be_only_a_tag() { + let lock = b"version = 4\n[[package]]\nname = \"fictional\"\nversion = \"1.0.0\"\nsource = \"git+https://example.com/fictional?tag=v1\"\n"; + assert!( + parse_cargo(lock).is_err(), + "should require a full locked Git revision" + ); +} + +#[test] +fn cargo_material_source_cannot_be_mistyped_as_a_local_package() { + let lock = + b"version = 4\n[[package]]\nname = \"fictional\"\nversion = \"1.0.0\"\nsource = 42\n"; + assert!( + parse_cargo(lock).is_err(), + "should reject malformed acquisition identity" + ); +} + +#[test] +fn npm_names_cannot_change_the_registry_path() { + let integrity = format!("sha512-{}", STANDARD.encode([0_u8; 64])); + let mut lock: serde_json::Value = + serde_json::from_slice(&npm_lock(&integrity)).expect("should parse fictional lockfile"); + lock["packages"]["node_modules/fictional-package"]["name"] = json!("../fictional-package"); + lock["packages"]["node_modules/fictional-package"]["resolved"] = json!(format!( + "{}../fictional-package/-/fictional-package-1.0.0.tgz", + locked::NPM_REGISTRY + )); + assert!( + parse_npm(&serde_json::to_vec(&lock).expect("should encode fixture")).is_err(), + "should reject traversal in the declared package identity" + ); +} + +#[test] +fn source_lockfiles_account_for_each_supported_acquisition_class() { + let cargo = parse_cargo(include_bytes!("../../../../Cargo.lock")) + .expect("should parse the source-controlled Cargo lock"); + assert!( + !cargo.registry.is_empty(), + "should retain registry originals" + ); + assert!( + !cargo.git.is_empty(), + "should retain exact Git revisions as unverified expectations" + ); + let npm = parse_npm(include_bytes!( + "../../../trusted-server-js/lib/package-lock.json" + )) + .expect("should parse the source-controlled npm lock"); + for package in [ + "node_modules/@esbuild/darwin-arm64", + "node_modules/@esbuild/darwin-x64", + "node_modules/@esbuild/linux-x64", + ] { + assert!( + npm.contains_key(package), + "should retain selected native esbuild originals despite optional flags" + ); + } +} + +#[test] +fn optional_npm_original_still_requires_its_exact_bytes() { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let original = directory.path().join("original.tgz"); + let bytes = b"fictional original archive bytes"; + std::fs::write(&original, bytes).expect("should create fictional original"); + let packages = parse_npm(&npm_lock(&format!( + "sha512-{}", + STANDARD.encode(Sha512::digest(bytes)) + ))) + .expect("should parse locked archive"); + let deadline = Instant::now() + Duration::from_secs(5); + let expected = &packages["node_modules/fictional-package"].sha512; + let mut retained = archive::verify( + &original, + archive::Expected::Sha512(expected), + "fictional-source", + "npm:node_modules/fictional-package", + deadline, + &Cancellation::default(), + ) + .expect("should verify the exact original"); + let mut changed = bytes.to_vec(); + changed[0] ^= 1; + std::fs::write(&original, changed).expect("should substitute one byte at the same path"); + assert!( + archive::verify( + &original, + archive::Expected::Sha512(expected), + "fictional-source", + "npm:node_modules/fictional-package", + deadline, + &Cancellation::default() + ) + .is_err(), + "should reject a changed original even when its filename is unchanged" + ); + let mut copy = Vec::new(); + retained + .copy_verified(&mut copy) + .expect("should retain the original verified bytes"); + assert_eq!( + copy, bytes, + "should consume the held private bytes rather than reopening the replaced path" + ); +} + +#[test] +fn npm_selectors_and_nested_locations_are_not_ambiguous() { + let integrity = format!("sha512-{}", STANDARD.encode([0_u8; 64])); + for (field, value) in [ + ("name", json!(42)), + ("name", json!("another-package")), + ("link", json!("false")), + ] { + let mut lock: serde_json::Value = + serde_json::from_slice(&npm_lock(&integrity)).expect("should parse fixture"); + lock["packages"]["node_modules/fictional-package"][field] = value; + assert!( + parse_npm(&serde_json::to_vec(&lock).expect("should encode fixture")).is_err(), + "should reject mistyped or conflicting package selectors" + ); + } + let mut lock: serde_json::Value = + serde_json::from_slice(&npm_lock(&integrity)).expect("should parse fixture"); + let package = lock["packages"] + .as_object_mut() + .expect("should have packages") + .remove("node_modules/fictional-package") + .expect("should have fictional package"); + lock["packages"]["node_modules/not/a/package/node_modules/fictional-package"] = package; + assert!( + parse_npm(&serde_json::to_vec(&lock).expect("should encode fixture")).is_err(), + "should validate every nested installation component" + ); +} + +#[test] +fn cancellation_after_verification_prevents_material_consumption() { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let original = directory.path().join("original.crate"); + let bytes = b"fictional original"; + std::fs::write(&original, bytes).expect("should create fixture original"); + let cancellation = Cancellation::default(); + let expected = hex::encode(sha2::Sha256::digest(bytes)); + let mut retained = archive::verify( + &original, + archive::Expected::Sha256(&expected), + "fictional-source", + "crate:fictional-package@1.0.0", + Instant::now() + Duration::from_secs(5), + &cancellation, + ) + .expect("should verify original before cancellation"); + cancellation.cancel(); + let mut destination = Vec::new(); + assert!( + retained.copy_verified(&mut destination).is_err(), + "should retain the shared cancellation boundary through later use" + ); + assert!( + destination.is_empty(), + "should not emit cancelled material bytes" + ); +} + +#[test] +fn archive_inputs_reject_aliases_special_files_and_oversize() { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let regular = directory.path().join("original"); + let alias = directory.path().join("alias"); + let hard = directory.path().join("hard-link"); + let fifo = directory.path().join("fifo"); + let huge = directory.path().join("huge"); + std::fs::write(®ular, b"fictional").expect("should write original"); + symlink(®ular, &alias).expect("should create symlink"); + std::fs::hard_link(®ular, &hard).expect("should create hard link"); + let fifo_name = CString::new(fifo.as_os_str().as_bytes()).expect("should encode FIFO path"); + // SAFETY: The path is a live NUL-terminated string and mode is valid. + assert_eq!( + unsafe { libc::mkfifo(fifo_name.as_ptr(), 0o600) }, + 0, + "should create FIFO" + ); + std::fs::File::create(&huge) + .expect("should create sparse fixture") + .set_len(64 * 1024 * 1024 + 1) + .expect("should exceed archive limit"); + let digest = hex::encode(sha2::Sha256::digest(b"fictional")); + for path in [ + ®ular, + &alias, + &hard, + &fifo, + &huge, + &directory.path().to_path_buf(), + ] { + assert!( + archive::verify( + path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + Instant::now() + Duration::from_secs(5), + &Cancellation::default() + ) + .is_err(), + "should reject unsafe original before any blocking read" + ); + } +} + +#[test] +fn registry_original_and_attempt_bounds_are_checked_before_use() { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let path = directory.path().join("fictional.crate"); + let bytes = b"fictional original"; + std::fs::write(&path, bytes).expect("should write original"); + let digest = hex::encode(sha2::Sha256::digest(bytes)); + let cancellation = Cancellation::default(); + let deadline = Instant::now() + Duration::from_secs(5); + archive::verify( + &path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + deadline, + &cancellation, + ) + .expect("should verify registry SHA-256"); + let expired = Instant::now() + .checked_sub(Duration::from_secs(1)) + .expect("should form expired deadline"); + assert!( + archive::verify( + &path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + expired, + &cancellation + ) + .is_err(), + "should reject an expired attempt" + ); + cancellation.cancel(); + assert!( + archive::verify( + &path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + deadline, + &cancellation + ) + .is_err(), + "should reject cancellation before reading" + ); + let mut changed = bytes.to_vec(); + changed[0] ^= 1; + std::fs::write(&path, changed).expect("should alter one original byte"); + assert!( + archive::verify( + &path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + deadline, + &Cancellation::default() + ) + .is_err(), + "should reject changed registry bytes" + ); +} + +#[test] +fn npm_lockfile_duplicate_keys_are_rejected_before_identity_selection() { + assert!( + parse_npm(br#"{"lockfileVersion":3,"packages":{},"packages":{}}"#).is_err(), + "should reject ambiguous archive selection before parsing package identities" + ); +} diff --git a/crates/trusted-server-release/src/package.rs b/crates/trusted-server-release/src/package.rs new file mode 100644 index 000000000..4197f489b --- /dev/null +++ b/crates/trusted-server-release/src/package.rs @@ -0,0 +1,367 @@ +//! 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; + +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 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(); + 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(path.clone()) + { + return Err(rejected( + "package has prefixed paths, links or duplicate entries", + )); + } + let kind = header.entry_type().as_byte(); + let size = entry.size(); + if path == root || path == bin { + 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)?; + let facts = inspect_wasm(&module)?; + if facts.sha256() != expected_wasm_sha256 { + return Err(rejected( + "actual package module differs from independently verified executable", + )); + } + 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 000000000..95433d2cc --- /dev/null +++ b/crates/trusted-server-release/src/package/tests.rs @@ -0,0 +1,398 @@ +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"; + +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 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_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/policy.rs b/crates/trusted-server-release/src/policy.rs new file mode 100644 index 000000000..317ee99a7 --- /dev/null +++ b/crates/trusted-server-release/src/policy.rs @@ -0,0 +1,827 @@ +//! Compiled-in release cohort contract and strict parsing of tracked release inputs. +//! +//! The JSON inputs are compared against this source-owned contract; they never +//! supply commands, packages, targets or authority. Pending or JSON-qualified +//! pins remain diagnostics until authenticated acquisition receipts exist. + +use std::collections::BTreeSet; +use std::path::Path; + +use error_stack::Report; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::documents::{Role, parse_json, read_bounded}; +use crate::error::ReleaseError; +use tools::{BUILDER_ENVIRONMENT, ToolsDocument, check_tools}; + +mod tools; + +/// Source-relative path of the tracked cohort selection. +pub(crate) const COHORT_PATH: &str = "scripts/release/release-cohort.json"; +/// Source-relative path of the tracked tool, environment and recipe pins. +pub(crate) const TOOLS_PATH: &str = "scripts/release/release-tools.json"; +/// Source-relative path of the tracked consumer release policy. +pub(crate) const CLI_POLICY_PATH: &str = "crates/trusted-server-cli/release-policy.json"; +/// Branches whose tips may supply release sources; candidates cannot extend it. +pub(crate) const ELIGIBLE_BRANCHES: [&str; 1] = ["main"]; +/// Version of the tracked consumer release policy the compiled contract accepts. +pub(crate) const CLI_POLICY_VERSION: u64 = 1; +/// Retained cohort label; controlled index meaning remains destination-only. +pub(crate) const COHORT_ID: &str = "phase1-fastly-cli"; +/// Official repository named by every release index. +pub(crate) const REPOSITORY: &str = "IABTechLab/trusted-server"; +const SCHEMA_VERSION: u64 = 1; +const GH_VERSION: &str = "2.97.0"; +const ATTEST_VERSION: &str = "4.2.2"; +const ATTEST_REVISION: &str = "1e69f48acb82d1966a394da916b4c1698aa569d6"; +const READONLY_ACTIONS: [&str; 1] = ["readonly-verify"]; +const CLI_POLICY_FIELDS: [&str; 13] = [ + "artifacts", + "bounds", + "cohort", + "github_host", + "historical_producer_policies", + "identity", + "policy_version", + "predicates", + "public_actions", + "repository", + "repository_id", + "repository_owner_id", + "tool_versions", +]; +const NOT_ESTABLISHED: [&str; 4] = [ + "authenticated acquisition receipts (Task 8)", + "git executable identity (Task 8)", + "rustup override/toolchain selection (bootstrap, Task 8)", + "remote branch-tip ancestry and ruleset validation (Task 14)", +]; + +/// Targets of the four closed controlled-recipe destination rows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReleaseTarget { + Wasm32Wasip1, + Aarch64AppleDarwin, + X86_64AppleDarwin, + X86_64UnknownLinuxGnu, +} + +impl ReleaseTarget { + /// Returns the Rust target triple. + pub(crate) fn triple(self) -> &'static str { + match self { + Self::Wasm32Wasip1 => "wasm32-wasip1", + Self::Aarch64AppleDarwin => "aarch64-apple-darwin", + Self::X86_64AppleDarwin => "x86_64-apple-darwin", + Self::X86_64UnknownLinuxGnu => "x86_64-unknown-linux-gnu", + } + } + + /// Returns whether the target needs the explicit macOS loader floor. + // Only the Task 5/7 child-environment builder (`preflight::child`) calls this. + #[allow(dead_code)] + pub(crate) fn is_darwin(self) -> bool { + matches!(self, Self::Aarch64AppleDarwin | Self::X86_64AppleDarwin) + } +} + +/// One source-owned asset identity; JSON can only restate it. +pub(crate) struct FixedArtifact { + pub(crate) name: &'static str, + pub(crate) package: &'static str, + pub(crate) bin: &'static str, + pub(crate) target: ReleaseTarget, + kind: &'static str, + role: &'static str, + pub(crate) assurance_profile: &'static str, + capability: &'static str, + architecture: &'static str, + minimum_version: &'static str, + runtime: &'static str, + recipe_environment: &'static str, +} + +impl FixedArtifact { + /// Renders this asset's fixed release-index identity fields. + /// + /// The result is exactly the governing schema's per-asset `oneOf` row: the + /// name, role, kind, package, target, default variant, assurance profile, + /// the single compiled capability and the compatibility triple. Nothing in + /// it comes from JSON input, so it can never add `fastly-staging`. + #[allow(dead_code, reason = "consumed by Phase 3 index construction")] + pub(crate) fn index_identity(&self) -> Value { + json!({ + "name": self.name, + "role": self.role, + "kind": self.kind, + "package": self.package, + "target": self.target.triple(), + "variant": "default", + "assurance_profile": self.assurance_profile, + "capabilities": [self.capability], + "compatibility": { + "runtime": self.runtime, + "minimum_version": self.minimum_version, + "architecture": self.architecture, + }, + }) + } +} + +/// The four controlled destination rows, not the seven ordinary release assets. +/// +/// These recipes and index identities remain closed. Ordinary Axum metadata +/// below grants neither a controlled recipe nor seven-asset index authority. +pub(crate) const FIXED_ARTIFACTS: [FixedArtifact; 4] = [ + FixedArtifact { + name: "trusted-server-fastly.wasm", + package: "trusted-server-adapter-fastly", + bin: "trusted-server-adapter-fastly", + target: ReleaseTarget::Wasm32Wasip1, + kind: "wasm-core", + role: "server", + assurance_profile: "provenance-sbom-repeatable", + capability: "fastly-production", + architecture: "wasm32", + minimum_version: "wasip1", + runtime: "fastly-compute", + recipe_environment: BUILDER_ENVIRONMENT, + }, + FixedArtifact { + name: "ts-aarch64-apple-darwin", + package: "trusted-server-cli", + bin: "ts", + target: ReleaseTarget::Aarch64AppleDarwin, + kind: "native-executable", + role: "operator-cli", + assurance_profile: "provenance-sbom", + capability: "cli-execute", + architecture: "aarch64", + minimum_version: "13.0", + runtime: "macos", + recipe_environment: "macos15-aarch64", + }, + FixedArtifact { + name: "ts-x86_64-apple-darwin", + package: "trusted-server-cli", + bin: "ts", + target: ReleaseTarget::X86_64AppleDarwin, + kind: "native-executable", + role: "operator-cli", + assurance_profile: "provenance-sbom", + capability: "cli-execute", + architecture: "x86_64", + minimum_version: "13.0", + runtime: "macos", + recipe_environment: "macos15-x86_64", + }, + FixedArtifact { + name: "ts-x86_64-unknown-linux-gnu", + package: "trusted-server-cli", + bin: "ts", + target: ReleaseTarget::X86_64UnknownLinuxGnu, + kind: "native-executable", + role: "operator-cli", + assurance_profile: "provenance-sbom", + capability: "cli-execute", + architecture: "x86_64", + minimum_version: "glibc-2.34", + runtime: "linux-gnu", + recipe_environment: BUILDER_ENVIRONMENT, + }, +]; + +/// Source-owned ordinary metadata, without a controlled target or recipe. +struct OrdinaryArtifact { + name: &'static str, + package: &'static str, + bin: &'static str, + target: &'static str, + kind: &'static str, + role: &'static str, + assurance_profile: &'static str, + capability: &'static str, + architecture: &'static str, + minimum_version: &'static str, + runtime: &'static str, +} + +fn ordinary_artifacts() -> Vec { + let mut artifacts: Vec<_> = FIXED_ARTIFACTS + .iter() + .map(|fixed| OrdinaryArtifact { + name: fixed.name, + package: fixed.package, + bin: fixed.bin, + target: fixed.target.triple(), + kind: fixed.kind, + role: fixed.role, + assurance_profile: fixed.assurance_profile, + capability: fixed.capability, + architecture: fixed.architecture, + minimum_version: fixed.minimum_version, + runtime: fixed.runtime, + }) + .collect(); + for (name, target, architecture, minimum_version, runtime) in [ + ( + "trusted-server-axum-x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + "x86_64", + "glibc-2.34", + "linux-gnu", + ), + ( + "trusted-server-axum-x86_64-unknown-linux-musl", + "x86_64-unknown-linux-musl", + "x86_64", + "static", + "linux-musl", + ), + ( + "trusted-server-axum-aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "aarch64", + "glibc-2.34", + "linux-gnu", + ), + ] { + artifacts.push(OrdinaryArtifact { + name, + target, + architecture, + minimum_version, + runtime, + package: "trusted-server-adapter-axum", + bin: "trusted-server-axum", + kind: "native-executable", + role: "server", + assurance_profile: "provenance-sbom", + capability: "axum-production", + }); + } + artifacts +} + +/// Test-only rows naming the members of the Cargo denial fixture workspace. +/// +/// They are never part of [`FIXED_ARTIFACTS`] or the cohort, so no request +/// text can select them; only test code reaches them by package name. +#[cfg(test)] +pub(crate) const DENIAL_FIXTURE_ARTIFACTS: [FixedArtifact; 4] = [ + denial_fixture_row("denial-build-direct.wasm", "build-direct"), + denial_fixture_row("denial-build-grandchild.wasm", "build-grandchild"), + denial_fixture_row("denial-macro-attempt.wasm", "macro-attempt"), + denial_fixture_row("denial-control.wasm", "control"), +]; + +/// Test-only rows naming the Cargo denial fixture members built for each +/// macOS CPU, so the macOS candidate jobs can prove build-script, grandchild +/// and procedural-macro denial through the actual Darwin recipe. +/// +/// Like [`DENIAL_FIXTURE_ARTIFACTS`] they are outside [`FIXED_ARTIFACTS`] +/// and the cohort; only test code reaches them by package and target. +#[cfg(test)] +pub(crate) const DARWIN_DENIAL_FIXTURE_ARTIFACTS: [FixedArtifact; 8] = [ + darwin_denial_row( + "denial-build-direct-aarch64-apple-darwin", + "build-direct", + ARM64, + ), + darwin_denial_row( + "denial-build-grandchild-aarch64-apple-darwin", + "build-grandchild", + ARM64, + ), + darwin_denial_row( + "denial-macro-attempt-aarch64-apple-darwin", + "macro-attempt", + ARM64, + ), + darwin_denial_row("denial-control-aarch64-apple-darwin", "control", ARM64), + darwin_denial_row( + "denial-build-direct-x86_64-apple-darwin", + "build-direct", + INTEL, + ), + darwin_denial_row( + "denial-build-grandchild-x86_64-apple-darwin", + "build-grandchild", + INTEL, + ), + darwin_denial_row( + "denial-macro-attempt-x86_64-apple-darwin", + "macro-attempt", + INTEL, + ), + darwin_denial_row("denial-control-x86_64-apple-darwin", "control", INTEL), +]; + +/// The arm64 macOS target of the Darwin denial rows. +#[cfg(test)] +const ARM64: ReleaseTarget = ReleaseTarget::Aarch64AppleDarwin; +/// The Intel macOS target of the Darwin denial rows. +#[cfg(test)] +const INTEL: ReleaseTarget = ReleaseTarget::X86_64AppleDarwin; + +/// One Darwin denial fixture row for `target`. +#[cfg(test)] +const fn darwin_denial_row( + name: &'static str, + package: &'static str, + target: ReleaseTarget, +) -> FixedArtifact { + // Evaluated at compile time, so a non-Darwin row fails the build. + let architecture = match target { + ReleaseTarget::Aarch64AppleDarwin => "aarch64", + ReleaseTarget::X86_64AppleDarwin => "x86_64", + ReleaseTarget::Wasm32Wasip1 | ReleaseTarget::X86_64UnknownLinuxGnu => { + panic!("Darwin denial rows need a Darwin target") + } + }; + FixedArtifact { + target, + architecture, + minimum_version: "13.0", + runtime: "macos", + ..denial_fixture_row(name, package) + } +} + +/// One Wasm denial fixture row whose binary is named after its package. +#[cfg(test)] +const fn denial_fixture_row(name: &'static str, package: &'static str) -> FixedArtifact { + FixedArtifact { + name, + package, + bin: package, + target: ReleaseTarget::Wasm32Wasip1, + kind: "denial-fixture", + role: "test-fixture", + assurance_profile: "none", + capability: "none", + architecture: "wasm32", + minimum_version: "wasip1", + runtime: "none", + recipe_environment: "ordinary-ci", + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CohortDocument { + artifacts: Vec, + cohort: String, + eligible_branches: Vec, + schema_version: u64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CohortArtifact { + assurance_profile: String, + bin: String, + capabilities: Vec, + compatibility: Compatibility, + default_features: bool, + features: Vec, + kind: String, + name: String, + package: String, + role: String, + target: String, + variant: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Compatibility { + architecture: String, + minimum_version: String, + runtime: String, +} + +/// Raw-byte identity of one tracked input file. +struct InputDigest { + path: &'static str, + sha256: String, +} + +/// Parsed tracked release inputs that match the compiled contract. +/// +/// This is not authorization: pins may still be pending or unauthenticated. +pub(crate) struct ReleaseInputs { + digests: [InputDigest; 3], + tools: ToolsDocument, +} + +impl ReleaseInputs { + /// Returns the source-relative path and raw SHA-256 of each input file. + pub(crate) fn input_digests(&self) -> [(&'static str, &str); 3] { + self.digests + .each_ref() + .map(|digest| (digest.path, digest.sha256.as_str())) + } + + fn pending_items(&self) -> Vec { + self.tools.pending_items() + } +} + +/// An authenticated acquisition and measured-pin receipt. +/// +/// No receipt verifier exists yet (Task 8), so this type is uninhabited and +/// production selection cannot succeed. +pub(crate) enum AcquisitionReceipt {} + +/// Production tool selection; constructed only by [`select_qualified`]. +pub(crate) struct QualifiedSelection<'a> { + receipts: Vec, + inputs: &'a ReleaseInputs, +} + +impl QualifiedSelection<'_> { + /// Summarizes the selected inputs for the caller's diagnostics. + pub(crate) fn summary(&self) -> String { + format!( + "selected {} authenticated pins from {} inputs", + self.receipts.len(), + self.inputs.digests.len() + ) + } +} + +/// Selects production pins only when every pin is qualified and authenticated. +/// +/// # Errors +/// +/// Fails on any pending qualification or null pin, and otherwise fails because +/// authenticated acquisition receipts cannot be constructed yet. +pub(crate) fn select_qualified<'a>( + inputs: &'a ReleaseInputs, + receipts: &[AcquisitionReceipt], +) -> Result, Report> { + let pending = inputs.pending_items(); + if !pending.is_empty() { + return Err(Report::new(ReleaseError::Unqualified).attach(format!( + "{} release pins are pending or null; production selection requires every pin qualified: {}", + pending.len(), + pending.join("; ") + ))); + } + let verified: Vec = receipts.iter().map(|receipt| match *receipt {}).collect(); + if verified.is_empty() { + return Err(Report::new(ReleaseError::Unqualified).attach( + "qualified pins require authenticated acquisition receipts (Task 8); \ + production selection remains closed", + )); + } + Ok(QualifiedSelection { + receipts: verified, + inputs, + }) +} + +/// Diagnostic description of incomplete inputs. It authorizes nothing. +pub(crate) struct InputAudit<'a> { + inputs: &'a ReleaseInputs, + pending: Vec, +} + +impl InputAudit<'_> { + /// Returns whether every JSON pin and external authentication is present. + /// + /// This is always `false` while [`NOT_ESTABLISHED`] lists facts this tool + /// cannot yet establish, so the diagnostic can never report completion. + pub(crate) fn is_complete(&self) -> bool { + self.pending.is_empty() && NOT_ESTABLISHED.is_empty() + } + + /// Renders the deterministic diagnostic report. + pub(crate) fn to_json(&self) -> Value { + json!({ + "inputs": self + .inputs + .input_digests() + .iter() + .map(|(path, sha256)| json!({"path": path, "sha256": sha256})) + .collect::>(), + "not_established": NOT_ESTABLISHED, + "pending": self.pending, + "report": "release-input-audit", + "status": if self.is_complete() { "complete" } else { "incomplete" }, + }) + } +} + +/// Lists pending and null items for diagnostics without constructing authority. +pub(crate) fn audit_inputs(inputs: &ReleaseInputs) -> InputAudit<'_> { + InputAudit { + inputs, + pending: inputs.pending_items(), + } +} + +/// Reads the three tracked inputs below `root` and checks them. +/// +/// # Errors +/// +/// Fails when any input cannot be read under its byte bound or violates the +/// compiled contract. +pub(crate) fn load_inputs(root: &Path) -> Result> { + let cohort = read_bounded(&root.join(COHORT_PATH), Role::Metadata)?; + let tools = read_bounded(&root.join(TOOLS_PATH), Role::Metadata)?; + let cli_policy = read_bounded(&root.join(CLI_POLICY_PATH), Role::Metadata)?; + check_inputs(&cohort.bytes, &tools.bytes, &cli_policy.bytes) +} + +/// Parses and checks exact input bytes against the compiled contract. +/// +/// # Errors +/// +/// Fails on malformed JSON, unknown or missing fields, or any deviation from +/// the compiled cohort, tool pins, recipes or consumer policy join. +pub(crate) fn check_inputs( + cohort: &[u8], + tools: &[u8], + cli_policy: &[u8], +) -> Result> { + let cohort_value = parse_json(cohort, Role::Metadata)?; + let cohort_document: CohortDocument = typed(&cohort_value, COHORT_PATH)?; + check_cohort(&cohort_document)?; + let tools_document: ToolsDocument = typed(&parse_json(tools, Role::Metadata)?, TOOLS_PATH)?; + check_tools(&tools_document)?; + check_cli_policy(&parse_json(cli_policy, Role::Metadata)?, &cohort_value)?; + let digest = |path, bytes: &[u8]| InputDigest { + path, + sha256: hex::encode(Sha256::digest(bytes)), + }; + Ok(ReleaseInputs { + digests: [ + digest(COHORT_PATH, cohort), + digest(TOOLS_PATH, tools), + digest(CLI_POLICY_PATH, cli_policy), + ], + tools: tools_document, + }) +} + +fn typed(value: &Value, path: &str) -> Result> { + T::deserialize(value).map_err(|error| rejected(format!("{path}: {error}"))) +} + +fn rejected(message: impl Into) -> Report { + Report::new(ReleaseError::Policy).attach(message.into()) +} + +fn check_cohort(cohort: &CohortDocument) -> Result<(), Report> { + if cohort.schema_version != SCHEMA_VERSION { + return Err(rejected("cohort schema_version must be 1")); + } + if cohort.cohort != COHORT_ID { + return Err(rejected(format!("cohort must be {COHORT_ID}"))); + } + if cohort.eligible_branches != ELIGIBLE_BRANCHES { + return Err(rejected( + "eligible_branches must be exactly [\"main\"]; a candidate cohort cannot \ + approve its own branch without independent review", + )); + } + let mut seen = BTreeSet::new(); + let ordinary = ordinary_artifacts(); + for artifact in &cohort.artifacts { + if !seen.insert(artifact.name.as_str()) { + return Err(rejected(format!("duplicate asset {}", artifact.name))); + } + let fixed = ordinary + .iter() + .find(|fixed| fixed.name == artifact.name) + .ok_or_else(|| rejected(format!("unknown asset {}", artifact.name)))?; + if !ordinary.iter().any(|fixed| fixed.target == artifact.target) { + return Err(rejected(format!( + "asset {} selects unknown target {}", + artifact.name, artifact.target + ))); + } + check_artifact(fixed, artifact)?; + } + if let Some(missing) = ordinary.iter().find(|fixed| !seen.contains(fixed.name)) { + return Err(rejected(format!("missing asset {}", missing.name))); + } + Ok(()) +} + +fn check_artifact( + fixed: &OrdinaryArtifact, + artifact: &CohortArtifact, +) -> Result<(), Report> { + let name = fixed.name; + if !artifact.features.is_empty() { + return Err(rejected(format!( + "asset {name} requests unsupported features {:?}; only the default variant \ + with reusable-sandbox disabled is attested", + artifact.features + ))); + } + if !artifact.default_features || artifact.variant != "default" { + return Err(rejected(format!( + "asset {name} must keep default_features true and variant default" + ))); + } + for (field, expected, actual) in identity_fields(fixed, artifact) { + if expected != actual { + return Err(rejected(format!( + "asset {name} changes {field} from {expected} to {actual}" + ))); + } + } + if artifact.capabilities != [fixed.capability] { + return Err(rejected(format!( + "asset {name} changes capabilities from [{}]", + fixed.capability + ))); + } + Ok(()) +} + +/// Field name, compiled value and candidate value of each fixed identity field. +fn identity_fields<'a>( + fixed: &OrdinaryArtifact, + artifact: &'a CohortArtifact, +) -> [(&'static str, &'static str, &'a str); 9] { + let compatibility = &artifact.compatibility; + [ + ("package", fixed.package, artifact.package.as_str()), + ("bin", fixed.bin, artifact.bin.as_str()), + ("target", fixed.target, artifact.target.as_str()), + ("kind", fixed.kind, artifact.kind.as_str()), + ("role", fixed.role, artifact.role.as_str()), + ( + "assurance_profile", + fixed.assurance_profile, + artifact.assurance_profile.as_str(), + ), + ( + "compatibility.architecture", + fixed.architecture, + compatibility.architecture.as_str(), + ), + ( + "compatibility.minimum_version", + fixed.minimum_version, + compatibility.minimum_version.as_str(), + ), + ( + "compatibility.runtime", + fixed.runtime, + compatibility.runtime.as_str(), + ), + ] +} + +/// Every non-artifact field of the tracked consumer release policy. +fn compiled_consumer_policy() -> Value { + let workflow = "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/"; + json!({ + "bounds": { + "attempt_seconds": 900, + "bundle_bytes": 33_554_432, + "child_seconds": 120, + "cohort_executables": FIXED_ARTIFACTS.len(), + "companion_bytes": 33_554_432, + "executable_bytes": 536_870_912, + "index_bytes": 1_048_576, + "inventory_bytes": 1_048_576, + "json_depth": 64, + "matching_attestations_per_bundle": 1, + "metadata_bytes": 1_048_576, + "policy_bytes": 1_048_576, + "spdx_bytes": 16_777_216, + "stderr_bytes": 1_048_576, + "stdout_bytes": 134_217_728, + "trusted_roots_bytes": 1_048_576, + }, + "cohort": COHORT_ID, + "github_host": "github.com", + "historical_producer_policies": [], + "identity": { + "builder_id": workflow, + "caller_commit": "same-approved-source-commit", + "caller_path": ".github/workflows/release.yml", + "caller_repository": "https://github.com/IABTechLab/trusted-server", + "certificate_fields": [ + "buildConfigDigest", + "buildConfigURI", + "buildTrigger", + "runInvocationURI", + "sourceRepositoryIdentifier", + "sourceRepositoryOwnerIdentifier", + ], + "certificate_san": workflow, + "event": "push", + "external_parameters": "exact-caller-workflow-only-no-inputs", + "invocation": "same-repository-run-and-attempt-across-certificate-and-provenance", + "issuer": "https://token.actions.githubusercontent.com", + "runner": "github-hosted", + "signer_commit": "same-approved-source-commit", + "source_commit": "independently-approved-40-lowercase-hex", + "source_dependency": "git+https://github.com/IABTechLab/trusted-server@refs/tags/", + "source_ref": "refs/tags/", + }, + "policy_version": CLI_POLICY_VERSION, + "predicates": { + "build_type": "https://actions.github.io/buildtypes/workflow/v1", + "provenance": "https://slsa.dev/provenance/v1", + "sbom": "https://spdx.dev/Document/v2.3", + }, + "public_actions": READONLY_ACTIONS, + "repository": REPOSITORY, + "repository_id": "950860906", + "repository_owner_id": "82903538", + "tool_versions": { + "actions_attest": ATTEST_VERSION, + "actions_attest_revision": ATTEST_REVISION, + "gh": GH_VERSION, + }, + }) +} + +fn check_cli_policy(policy: &Value, cohort: &Value) -> Result<(), Report> { + let object = policy + .as_object() + .ok_or_else(|| rejected("release-policy must be an object"))?; + let keys: BTreeSet<&str> = object.keys().map(String::as_str).collect(); + if keys != BTreeSet::from(CLI_POLICY_FIELDS) { + return Err(rejected( + "release-policy must contain exactly the reviewed top-level fields", + )); + } + let compiled = compiled_consumer_policy(); + for key in CLI_POLICY_FIELDS.iter().filter(|key| **key != "artifacts") { + if object.get(*key) != compiled.get(*key) { + return Err(rejected(format!( + "release-policy field {key} differs from the compiled consumer policy" + ))); + } + } + check_policy_artifacts(&object["artifacts"], cohort) +} + +/// Joins the exact four controlled destination rows plus read-only actions. +/// +/// The ordinary seven-row cohort has already passed strict validation. Its +/// additional Axum metadata cannot expand the carried consumer/index contract. +fn check_policy_artifacts(policy: &Value, cohort: &Value) -> Result<(), Report> { + let policy_artifacts = policy + .as_array() + .ok_or_else(|| rejected("release-policy artifacts must be an array"))?; + let cohort_artifacts = cohort["artifacts"] + .as_array() + .ok_or_else(|| rejected("cohort artifacts must be an array"))?; + if policy_artifacts.len() != FIXED_ARTIFACTS.len() { + return Err(rejected( + "release-policy artifacts must list exactly the four controlled destination artifacts", + )); + } + let readonly = json!(READONLY_ACTIONS); + for (policy_artifact, fixed) in policy_artifacts.iter().zip(&FIXED_ARTIFACTS) { + let matching: Vec<_> = cohort_artifacts + .iter() + .filter(|row| row["name"] == fixed.name) + .collect(); + if matching.len() != 1 { + return Err(rejected( + "controlled destination row is missing or duplicated", + )); + } + let cohort_artifact = matching[0]; + let policy_object = policy_artifact + .as_object() + .ok_or_else(|| rejected("release-policy artifact must be an object"))?; + let cohort_object = cohort_artifact + .as_object() + .ok_or_else(|| rejected("cohort artifact must be an object"))?; + if policy_object.get("actions") != Some(&readonly) { + return Err(rejected( + "release-policy artifact actions must be exactly [\"readonly-verify\"]", + )); + } + let joined = policy_object.len() == cohort_object.len() + 1 + && cohort_object + .iter() + .all(|(key, value)| key != "actions" && policy_object.get(key) == Some(value)); + if !joined { + return Err(rejected(format!( + "release-policy artifact {} differs from the cohort artifact", + cohort_artifact["name"] + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/policy/tests.rs b/crates/trusted-server-release/src/policy/tests.rs new file mode 100644 index 000000000..4bc192f2e --- /dev/null +++ b/crates/trusted-server-release/src/policy/tests.rs @@ -0,0 +1,738 @@ +use serde_json::json; +use sha2::Sha256; + +use super::*; + +const COHORT: &[u8] = include_bytes!("../../../../scripts/release/release-cohort.json"); +const TOOLS: &[u8] = include_bytes!("../../../../scripts/release/release-tools.json"); +const CLI_POLICY: &[u8] = + include_bytes!("../../../../crates/trusted-server-cli/release-policy.json"); + +#[test] +fn pending_tool_versions_match_the_source_selected_toolchain() { + let tools = value(TOOLS); + let source = include_str!("../../../../.tool-versions"); + for (release_name, source_name) in [ + ("node", "nodejs"), + ("rust", "rust"), + ("fastly", "fastly"), + ("viceroy", "viceroy"), + ("wasmtime", "wasmtime"), + ] { + let version = source + .lines() + .find_map(|line| { + let mut fields = line.split_whitespace(); + (fields.next() == Some(source_name)) + .then(|| fields.next()) + .flatten() + }) + .expect("should contain the source tool version"); + for tool in tools["tools"].as_array().expect("should hold tool pins") { + if tool["name"] + .as_str() + .is_some_and(|name| name.split(':').next() == Some(release_name)) + { + assert_eq!( + tool["version"], version, + "should keep pending {release_name} pins aligned with source selection" + ); + } + } + } +} + +fn value(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).expect("should parse tracked fixture input") +} + +fn encode(value: &Value) -> Vec { + serde_json::to_vec(value).expect("should encode mutated fixture input") +} + +fn report_text(report: &Report) -> String { + format!("{report:?}") +} + +fn cohort_with(mutate: impl FnOnce(&mut Value)) -> Result> { + let mut cohort = value(COHORT); + mutate(&mut cohort); + check_inputs(&encode(&cohort), TOOLS, CLI_POLICY) +} + +fn tools_with(mutate: impl FnOnce(&mut Value)) -> Result> { + let mut tools = value(TOOLS); + mutate(&mut tools); + check_inputs(COHORT, &encode(&tools), CLI_POLICY) +} + +fn policy_with(mutate: impl FnOnce(&mut Value)) -> Result> { + let mut policy = value(CLI_POLICY); + mutate(&mut policy); + check_inputs(COHORT, TOOLS, &encode(&policy)) +} + +fn artifact_index(document: &Value, name: &str) -> usize { + document["artifacts"] + .as_array() + .expect("should contain artifacts") + .iter() + .position(|artifact| artifact["name"] == name) + .expect("should contain named artifact") +} + +fn expect_rejected(result: Result>, needle: &str) { + let report = result.err().expect("should reject mutated release input"); + let text = report_text(&report); + assert!( + text.contains(needle), + "should explain rejection with `{needle}`, got: {text}" + ); +} + +#[test] +fn accepts_tracked_inputs_and_preserves_raw_digests() { + let inputs = check_inputs(COHORT, TOOLS, CLI_POLICY).expect("should accept tracked inputs"); + let digests = inputs + .input_digests() + .map(|(path, sha256)| (path, sha256.to_owned())); + assert_eq!( + digests, + [ + (COHORT_PATH, hex::encode(Sha256::digest(COHORT))), + (TOOLS_PATH, hex::encode(Sha256::digest(TOOLS))), + (CLI_POLICY_PATH, hex::encode(Sha256::digest(CLI_POLICY))), + ], + "should preserve exact raw-byte digests of each input" + ); +} + +#[test] +fn compiled_contract_maps_exactly_four_fixed_assets() { + let names: Vec<_> = FIXED_ARTIFACTS.iter().map(|fixed| fixed.name).collect(); + assert_eq!( + names, + [ + "trusted-server-fastly.wasm", + "ts-aarch64-apple-darwin", + "ts-x86_64-apple-darwin", + "ts-x86_64-unknown-linux-gnu", + ], + "should retain exactly the four controlled destination identities" + ); + let fastly = &FIXED_ARTIFACTS[0]; + assert_eq!(fastly.package, "trusted-server-adapter-fastly"); + assert_eq!(fastly.bin, "trusted-server-adapter-fastly"); + assert_eq!(fastly.target, ReleaseTarget::Wasm32Wasip1); + assert_eq!(fastly.assurance_profile, "provenance-sbom-repeatable"); + for cli in &FIXED_ARTIFACTS[1..] { + assert_eq!(cli.package, "trusted-server-cli", "should build the CLI"); + assert_eq!(cli.bin, "ts", "should build the ts binary"); + assert_eq!(cli.assurance_profile, "provenance-sbom"); + } + assert_eq!(ELIGIBLE_BRANCHES, ["main"], "should only allow main"); +} + +#[test] +fn ordinary_seven_asset_cohort_does_not_select_controlled_axum_recipes() { + check_inputs(COHORT, TOOLS, CLI_POLICY).expect("should validate all seven ordinary asset rows"); + let cohort = value(COHORT); + let rows = cohort["artifacts"] + .as_array() + .expect("should list cohort rows"); + assert_eq!(rows.len(), 7, "should validate the actual ordinary cohort"); + for asset in rows.iter().skip(4) { + let name = asset["name"].as_str().expect("should name the Axum asset"); + assert!( + crate::recipe::AssetId::parse(name).is_err(), + "should not grant controlled recipe selection for {name}" + ); + } + assert_eq!( + value(CLI_POLICY)["artifacts"] + .as_array() + .expect("should list controlled consumer rows") + .len(), + 4, + "should preserve the separately gated index destination contract" + ); +} + +#[test] +fn ordinary_axum_rows_reject_every_changed_identity_and_missing_row() { + for index in 4..7 { + for field in [ + "package", + "bin", + "target", + "role", + "kind", + "assurance_profile", + "variant", + ] { + let mut cohort = value(COHORT); + cohort["artifacts"][index][field] = json!("example-invalid"); + assert!( + check_inputs(&encode(&cohort), TOOLS, CLI_POLICY).is_err(), + "should reject Axum identity change {index}/{field}" + ); + } + for field in ["architecture", "minimum_version", "runtime"] { + let mut cohort = value(COHORT); + cohort["artifacts"][index]["compatibility"][field] = json!("example-invalid"); + assert!( + check_inputs(&encode(&cohort), TOOLS, CLI_POLICY).is_err(), + "should reject Axum compatibility change {index}/{field}" + ); + } + let mut cohort = value(COHORT); + cohort["artifacts"] + .as_array_mut() + .expect("should list cohort rows") + .remove(index); + assert!( + check_inputs(&encode(&cohort), TOOLS, CLI_POLICY).is_err(), + "should require Axum row {index}" + ); + } +} + +#[test] +fn rejects_missing_artifact() { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "ts-x86_64-apple-darwin"); + cohort["artifacts"] + .as_array_mut() + .expect("should contain artifacts") + .remove(index); + }), + "missing asset ts-x86_64-apple-darwin", + ); +} + +#[test] +fn rejects_extra_artifact() { + expect_rejected( + cohort_with(|cohort| { + let mut extra = cohort["artifacts"][0].clone(); + extra["name"] = json!("trusted-server-fastly-reuse.wasm"); + cohort["artifacts"] + .as_array_mut() + .expect("should contain artifacts") + .push(extra); + }), + "unknown asset trusted-server-fastly-reuse.wasm", + ); +} + +#[test] +fn rejects_duplicate_artifact() { + expect_rejected( + cohort_with(|cohort| { + let duplicate = cohort["artifacts"][0].clone(); + cohort["artifacts"] + .as_array_mut() + .expect("should contain artifacts") + .push(duplicate); + }), + "duplicate asset", + ); +} + +#[test] +fn rejects_profile_downgraded_after_failure() { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "trusted-server-fastly.wasm"); + cohort["artifacts"][index]["assurance_profile"] = json!("provenance-sbom"); + }), + "assurance_profile", + ); +} + +#[test] +fn rejects_unknown_target() { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "ts-x86_64-unknown-linux-gnu"); + cohort["artifacts"][index]["target"] = json!("example-unknown-linux-gnu"); + }), + "unknown target example-unknown-linux-gnu", + ); +} + +#[test] +fn rejects_reassigned_known_target() { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "ts-x86_64-apple-darwin"); + cohort["artifacts"][index]["target"] = json!("aarch64-apple-darwin"); + }), + "target", + ); +} + +#[test] +fn rejects_unknown_or_reuse_feature() { + for feature in ["reusable-sandbox", "fictional-feature"] { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "trusted-server-fastly.wasm"); + cohort["artifacts"][index]["features"] = json!([feature]); + }), + "unsupported features", + ); + } +} + +#[test] +fn rejects_disabled_default_features_and_changed_variant() { + expect_rejected( + cohort_with(|cohort| { + cohort["artifacts"][0]["default_features"] = json!(false); + }), + "default_features", + ); + expect_rejected( + cohort_with(|cohort| { + cohort["artifacts"][0]["variant"] = json!("reuse"); + }), + "variant", + ); +} + +#[test] +fn rejects_changed_package_bin_capability_and_compatibility() { + for (field, replacement) in [ + ("package", json!("trusted-server-adapter-axum")), + ("bin", json!("other")), + ("capabilities", json!(["cli-execute"])), + ("kind", json!("native-executable")), + ("role", json!("operator-cli")), + ] { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "trusted-server-fastly.wasm"); + cohort["artifacts"][index][field] = replacement; + }), + field, + ); + } + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "ts-x86_64-unknown-linux-gnu"); + cohort["artifacts"][index]["compatibility"]["minimum_version"] = json!("glibc-2.39"); + }), + "compatibility", + ); +} + +#[test] +fn rejects_candidate_cohort_listing_its_own_rc_branch() { + for branches in [ + json!(["main", "rc/202610"]), + json!(["rc/202610"]), + json!([]), + ] { + expect_rejected( + cohort_with(|cohort| { + cohort["eligible_branches"] = branches; + }), + "eligible_branches", + ); + } +} + +#[test] +fn rejects_changed_cohort_identity_and_schema_version() { + expect_rejected( + cohort_with(|cohort| cohort["cohort"] = json!("phase2")), + "cohort", + ); + expect_rejected( + cohort_with(|cohort| cohort["schema_version"] = json!(2)), + "schema_version", + ); +} + +#[test] +fn rejects_unknown_and_missing_cohort_fields() { + assert!( + cohort_with(|cohort| cohort["commands"] = json!(["cargo build"])).is_err(), + "should reject JSON-supplied commands" + ); + assert!( + cohort_with(|cohort| cohort["artifacts"][0]["command"] = json!("sh -c true")).is_err(), + "should reject artifact-level commands" + ); + assert!( + cohort_with(|cohort| { + cohort["artifacts"][0] + .as_object_mut() + .expect("should be an artifact object") + .remove("variant"); + }) + .is_err(), + "should reject a missing artifact field" + ); + assert!( + cohort_with(|cohort| { + cohort + .as_object_mut() + .expect("should be a cohort object") + .remove("eligible_branches"); + }) + .is_err(), + "should reject a missing cohort field" + ); +} + +#[test] +fn rejects_duplicate_keys_through_strict_parser() { + let duplicated = String::from_utf8(COHORT.to_vec()) + .expect("should be UTF-8") + .replacen( + "\"cohort\": \"phase1-fastly-cli\"", + "\"cohort\": \"phase1-fastly-cli\", \"cohort\": \"x\"", + 1, + ); + assert!( + check_inputs(duplicated.as_bytes(), TOOLS, CLI_POLICY).is_err(), + "should reject duplicate keys before semantic checks" + ); +} + +#[test] +fn joins_cli_policy_artifacts_to_cohort_with_readonly_actions() { + expect_rejected( + policy_with(|policy| { + policy["artifacts"][0]["actions"] = json!(["readonly-verify", "deploy"]) + }), + "actions", + ); + expect_rejected( + policy_with(|policy| { + policy["artifacts"][1] + .as_object_mut() + .expect("should be a policy artifact") + .remove("actions"); + }), + "actions", + ); + expect_rejected( + policy_with(|policy| policy["artifacts"][1]["assurance_profile"] = json!("provenance")), + "release-policy", + ); + expect_rejected( + policy_with(|policy| { + policy["artifacts"] + .as_array_mut() + .expect("should contain artifacts") + .pop(); + }), + "release-policy", + ); + assert!( + policy_with(|policy| policy["override_url"] = json!("https://example.com/policy.json")) + .is_err(), + "should reject unknown policy fields" + ); + expect_rejected( + policy_with(|policy| policy["tool_versions"]["gh"] = json!("2.96.0")), + "tool_versions", + ); + expect_rejected( + policy_with(|policy| policy["cohort"] = json!("phase2")), + "cohort", + ); +} + +#[test] +fn preserves_exact_tool_versions_and_attest_revision() { + expect_rejected( + tools_with(|tools| { + let tool = tools["tools"] + .as_array_mut() + .expect("should contain tools") + .iter_mut() + .find(|tool| tool["name"] == "gh:x86_64-unknown-linux-gnu") + .expect("should contain gh"); + tool["version"] = json!("2.96.0"); + }), + "gh:x86_64-unknown-linux-gnu", + ); + expect_rejected( + tools_with(|tools| { + tools["tools"][0]["revision"] = json!("0".repeat(40)); + }), + "actions/attest:github-action", + ); + expect_rejected( + tools_with(|tools| { + tools["tools"] + .as_array_mut() + .expect("should contain tools") + .retain(|tool| tool["name"] != "rust:x86_64-apple-darwin"); + }), + "missing tool rust:x86_64-apple-darwin", + ); + expect_rejected( + tools_with(|tools| { + let mut extra = tools["tools"][1].clone(); + extra["name"] = json!("fastly:aarch64-unknown-linux-gnu"); + tools["tools"] + .as_array_mut() + .expect("should contain tools") + .push(extra); + }), + "unknown tool fastly:aarch64-unknown-linux-gnu", + ); +} + +#[test] +fn rejects_absent_nullable_pin_fields_and_unknown_tool_fields() { + assert!( + tools_with(|tools| { + tools["tools"][1] + .as_object_mut() + .expect("should be a tool object") + .remove("sha256"); + }) + .is_err(), + "should treat an absent digest as invalid rather than null" + ); + assert!( + tools_with(|tools| tools["tools"][1]["qualified"] = json!(true)).is_err(), + "should reject serialized authority booleans" + ); + assert!( + tools_with(|tools| tools["environments"][0]["authenticated"] = json!(true)).is_err(), + "should reject unknown environment fields" + ); + assert!( + tools_with(|tools| tools["tools"][1]["qualification"] = json!("approved")).is_err(), + "should reject unknown qualification states" + ); + assert!( + tools_with(|tools| tools["tools"][1]["sha256"] = json!("not-a-digest")).is_err(), + "should reject malformed digests" + ); +} + +#[test] +fn rejects_recipe_script_and_environment_changes() { + expect_rejected( + tools_with(|tools| tools["recipes"][0]["script"] = json!("scripts/release/other.sh")), + "script", + ); + expect_rejected( + tools_with(|tools| tools["recipes"][1]["environment_id"] = json!("gnu-builder-x86_64")), + "environment_id", + ); + expect_rejected( + tools_with(|tools| tools["recipes"][0]["settings"]["aws_lc_use_system"] = json!(true)), + "settings", + ); + expect_rejected( + tools_with(|tools| tools["recipes"][0]["settings"]["rust_toolchain"] = json!("1.96.0")), + "settings", + ); + expect_rejected( + tools_with(|tools| { + tools["environments"][1]["glibc_maximum"] = json!("2.39"); + }), + "gnu-builder-x86_64", + ); +} + +#[test] +fn production_selector_rejects_tracked_pending_pins() { + let inputs = check_inputs(COHORT, TOOLS, CLI_POLICY).expect("should accept tracked inputs"); + let report = select_qualified(&inputs, &[]) + .err() + .expect("should reject pending pins"); + let text = report_text(&report); + assert!(text.contains("pending"), "should name pending pins: {text}"); +} + +fn qualified_looking_tools() -> Value { + let digest = "ab".repeat(32); + let mut tools = value(TOOLS); + for section in ["environments", "recipes", "tools"] { + for entry in tools[section] + .as_array_mut() + .expect("should contain entries") + { + let object = entry.as_object_mut().expect("should be an object"); + for (key, field) in object.iter_mut() { + if key == "qualification" { + *field = json!("qualified"); + } else if field.is_null() && key == "revision" { + continue; + } else if field.is_null() && key.ends_with("sha256") { + *field = json!(digest); + } else if field.is_null() { + *field = json!("fictional.example.com/qualified"); + } + } + } + } + tools +} + +#[test] +fn production_selector_rejects_qualified_looking_json_with_fake_digests() { + let inputs = check_inputs(COHORT, &encode(&qualified_looking_tools()), CLI_POLICY) + .expect("should parse structurally complete fictional pins"); + let report = select_qualified(&inputs, &[]) + .err() + .expect("should refuse JSON qualification without authenticated receipts"); + let text = report_text(&report); + assert!( + text.contains("authenticated acquisition receipts") + && text.contains("production selection remains closed"), + "should explain the closed selector: {text}" + ); +} + +#[test] +fn audit_lists_pending_items_without_authorization() { + let inputs = check_inputs(COHORT, TOOLS, CLI_POLICY).expect("should accept tracked inputs"); + let audit = audit_inputs(&inputs); + assert!(!audit.is_complete(), "should report incomplete inputs"); + let report = audit.to_json(); + let pending = report["pending"] + .as_array() + .expect("should list pending items"); + assert!( + pending.contains(&json!( + "tool gh:x86_64-unknown-linux-gnu: qualification pending" + )), + "should list pending tool qualification" + ); + assert!( + pending.contains(&json!("tool gh:x86_64-unknown-linux-gnu: sha256 is null")), + "should list null tool digest" + ); + assert!( + pending.contains(&json!("environment macos15-aarch64: sdk_sha256 is null")), + "should list null environment SDK digest" + ); + assert_eq!( + report["inputs"][0], + json!({"path": COHORT_PATH, "sha256": hex::encode(Sha256::digest(COHORT))}), + "should report raw input digests" + ); + assert_eq!(report["status"], json!("incomplete")); +} + +#[test] +fn audit_stays_incomplete_for_qualified_looking_json() { + let inputs = check_inputs(COHORT, &encode(&qualified_looking_tools()), CLI_POLICY) + .expect("should parse structurally complete fictional pins"); + let audit = audit_inputs(&inputs); + assert!( + audit.to_json()["pending"] + .as_array() + .expect("should list pending items") + .is_empty(), + "should find no pending JSON fields" + ); + assert!( + !audit.is_complete(), + "should remain incomplete without authenticated receipts" + ); +} + +fn recipe_index(tools: &Value, id: &str) -> usize { + tools["recipes"] + .as_array() + .expect("should contain recipes") + .iter() + .position(|recipe| recipe["id"] == id) + .expect("should contain recipe") +} + +#[test] +fn requires_exact_recipe_settings() { + let mutations: [(&str, &str, Value); 4] = [ + ("tsjs", "mandatory_tests", json!(false)), + ("tsjs", "npm_lifecycle", json!("unrestricted")), + ( + "trusted-server-fastly.wasm", + "cargo_args", + json!(["--offline"]), + ), + ("ts-aarch64-apple-darwin", "release_strip", json!("symbols")), + ]; + for (id, key, replacement) in mutations { + expect_rejected( + tools_with(|tools| { + let index = recipe_index(tools, id); + tools["recipes"][index]["settings"][key] = replacement; + }), + "settings", + ); + } + expect_rejected( + tools_with(|tools| { + let index = recipe_index(tools, "ts-x86_64-unknown-linux-gnu"); + tools["recipes"][index]["settings"]["ambient_profiles"]["B"]["umask"] = json!("000"); + }), + "settings", + ); + expect_rejected( + tools_with(|tools| { + let index = recipe_index(tools, "tsjs"); + tools["recipes"][index]["settings"] + .as_object_mut() + .expect("should be settings") + .remove("npm_offline"); + }), + "settings", + ); +} + +#[test] +fn requires_exact_consumer_policy_fields() { + for (pointer, replacement) in [ + ("/repository_id", json!("1")), + ("/repository_owner_id", json!("2")), + ("/repository", json!("example/fictional")), + ("/github_host", json!("github.example.com")), + ("/identity/issuer", json!("https://issuer.example.com")), + ("/identity/builder_id", json!("https://example.com/builder")), + ("/predicates/sbom", json!("https://example.com/sbom")), + ("/predicates/build_type", json!("https://example.com/build")), + ("/bounds/child_seconds", json!(121)), + ( + "/historical_producer_policies", + json!([{"policy_sha256": "a".repeat(64)}]), + ), + ("/policy_version", json!(2)), + ] { + expect_rejected( + policy_with(|policy| { + *policy + .pointer_mut(pointer) + .expect("should address a tracked policy field") = replacement; + }), + "release-policy", + ); + } +} + +#[test] +fn audit_completion_stays_false_while_facts_are_not_established() { + assert!( + !NOT_ESTABLISHED.is_empty(), + "should still list facts this tool cannot establish" + ); + let inputs = check_inputs(COHORT, &encode(&qualified_looking_tools()), CLI_POLICY) + .expect("should parse structurally complete fictional pins"); + assert!( + !audit_inputs(&inputs).is_complete(), + "should never report completion while any fact is not established" + ); +} diff --git a/crates/trusted-server-release/src/policy/tools.rs b/crates/trusted-server-release/src/policy/tools.rs new file mode 100644 index 000000000..fda46a4b2 --- /dev/null +++ b/crates/trusted-server-release/src/policy/tools.rs @@ -0,0 +1,539 @@ +//! Strict parsing and compiled-pin comparison of `release-tools.json`. +//! +//! Qualification states and digests parsed here are diagnostics only; they +//! never construct production authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map, Value, json}; + +use super::{ + ATTEST_REVISION, ATTEST_VERSION, FIXED_ARTIFACTS, GH_VERSION, SCHEMA_VERSION, rejected, +}; +use crate::documents::lower_hex; +use crate::error::ReleaseError; +use crate::recipe::AmbientProfile; + +const RUST_VERSION: &str = "1.98.1"; +const NODE_VERSION: &str = "24.20.0"; +const NPM_VERSION: &str = "11.19.0"; +const ATTEST_NAME: &str = "actions/attest:github-action"; +const ARTIFACT_SCRIPT: &str = "scripts/release/build.sh"; +const JS_RECIPE: &str = "tsjs"; +const JS_SCRIPT: &str = "scripts/release/build-js.sh"; +/// Builder environment shared by the Fastly, Linux CLI and JS recipes. +pub(super) const BUILDER_ENVIRONMENT: &str = "gnu-builder-x86_64"; +const TOOL_HOSTS: [&str; 3] = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", +]; +const PER_HOST_TOOLS: [(&str, &str); 7] = [ + ("fastly", "16.0.0"), + ("gh", GH_VERSION), + ("node", NODE_VERSION), + ("npm", NPM_VERSION), + ("rust", RUST_VERSION), + ("viceroy", "0.21.0"), + ("wasmtime", "48.0.1"), +]; + +/// Fixed environment identity: id, kind, role and kind-specific constant. +const FIXED_ENVIRONMENTS: [(&str, &str, &str, &str); 6] = [ + ( + "debian12-x86_64", + "oci-runtime", + "compatibility-runtime", + "", + ), + (BUILDER_ENVIRONMENT, "oci-builder", "compilation", "2.34"), + ( + "macos15-aarch64", + "github-hosted-macos", + "compilation", + "macos-15", + ), + ( + "macos15-x86_64", + "github-hosted-macos", + "compilation", + "macos-15-intel", + ), + ("ubi9-x86_64", "oci-runtime", "compatibility-runtime", ""), + ( + "ubuntu22.04-x86_64", + "oci-runtime", + "compatibility-runtime", + "", + ), +]; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum Qualification { + Pending, + Qualified, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +/// Parsed `release-tools.json` with closed per-kind field sets. +pub(super) struct ToolsDocument { + environments: Vec, + recipes: Vec, + schema_version: u64, + tools: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ToolPin { + #[serde(deserialize_with = "nullable")] + acquisition: Option, + name: String, + qualification: Qualification, + #[serde(deserialize_with = "nullable")] + revision: Option, + #[serde(deserialize_with = "nullable")] + sha256: Option, + version: String, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", deny_unknown_fields)] +enum Environment { + #[serde(rename = "oci-runtime")] + OciRuntime { + #[serde(deserialize_with = "nullable")] + acquisition: Option, + id: String, + #[serde(deserialize_with = "nullable")] + image_reference: Option, + #[serde(deserialize_with = "nullable")] + oci_index_sha256: Option, + #[serde(deserialize_with = "nullable")] + oci_manifest_sha256: Option, + qualification: Qualification, + role: String, + }, + #[serde(rename = "oci-builder")] + OciBuilder { + #[serde(deserialize_with = "nullable")] + acquisition: Option, + glibc_maximum: String, + id: String, + #[serde(deserialize_with = "nullable")] + image_reference: Option, + #[serde(deserialize_with = "nullable")] + oci_index_sha256: Option, + #[serde(deserialize_with = "nullable")] + oci_manifest_sha256: Option, + qualification: Qualification, + role: String, + #[serde(deserialize_with = "nullable")] + sdk_sha256: Option, + }, + #[serde(rename = "github-hosted-macos")] + HostedMacos { + #[serde(deserialize_with = "nullable")] + acquisition: Option, + id: String, + qualification: Qualification, + role: String, + #[serde(deserialize_with = "nullable")] + runner_image_version: Option, + runner_label: String, + #[serde(deserialize_with = "nullable")] + sdk_sha256: Option, + }, +} + +/// A nullable pin field: present-or-null, never absent. +struct PinField<'a> { + name: &'static str, + value: Option<&'a str>, + digest: bool, +} + +impl<'a> PinField<'a> { + fn new(name: &'static str, value: &'a Option, digest: bool) -> Self { + Self { + name, + value: value.as_deref(), + digest, + } + } +} + +impl Environment { + fn identity(&self) -> (&str, &'static str, &str, &str) { + match self { + Self::OciRuntime { id, role, .. } => (id, "oci-runtime", role, ""), + Self::OciBuilder { + id, + role, + glibc_maximum, + .. + } => (id, "oci-builder", role, glibc_maximum), + Self::HostedMacos { + id, + role, + runner_label, + .. + } => (id, "github-hosted-macos", role, runner_label), + } + } + + fn qualification(&self) -> Qualification { + match self { + Self::OciRuntime { qualification, .. } + | Self::OciBuilder { qualification, .. } + | Self::HostedMacos { qualification, .. } => *qualification, + } + } + + fn acquisition(&self) -> Option<&Value> { + match self { + Self::OciRuntime { acquisition, .. } + | Self::OciBuilder { acquisition, .. } + | Self::HostedMacos { acquisition, .. } => acquisition.as_ref(), + } + } + + fn pins(&self) -> Vec> { + let field = PinField::new; + match self { + Self::OciRuntime { + image_reference, + oci_index_sha256, + oci_manifest_sha256, + .. + } => vec![ + field("image_reference", image_reference, false), + field("oci_index_sha256", oci_index_sha256, true), + field("oci_manifest_sha256", oci_manifest_sha256, true), + ], + Self::OciBuilder { + image_reference, + oci_index_sha256, + oci_manifest_sha256, + sdk_sha256, + .. + } => vec![ + field("image_reference", image_reference, false), + field("oci_index_sha256", oci_index_sha256, true), + field("oci_manifest_sha256", oci_manifest_sha256, true), + field("sdk_sha256", sdk_sha256, true), + ], + Self::HostedMacos { + runner_image_version, + sdk_sha256, + .. + } => vec![ + field("runner_image_version", runner_image_version, false), + field("sdk_sha256", sdk_sha256, true), + ], + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Recipe { + environment_id: String, + id: String, + qualification: Qualification, + script: String, + settings: Map, + #[serde(deserialize_with = "nullable")] + sha256: Option, +} + +fn nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + +impl ToolsDocument { + /// Lists pending qualifications and null pins in document order. + pub(super) fn pending_items(&self) -> Vec { + let mut items = Vec::new(); + for environment in &self.environments { + note_pending( + &mut items, + &format!("environment {}", environment.identity().0), + environment.qualification(), + Some(environment.acquisition()), + &environment.pins(), + ); + } + for recipe in &self.recipes { + let digest = PinField::new("sha256", &recipe.sha256, true); + note_pending( + &mut items, + &format!("recipe {}", recipe.id), + recipe.qualification, + None, + &[digest], + ); + } + for tool in &self.tools { + let digest = PinField::new("sha256", &tool.sha256, true); + note_pending( + &mut items, + &format!("tool {}", tool.name), + tool.qualification, + Some(tool.acquisition.as_ref()), + &[digest], + ); + } + items + } +} + +/// Appends pending diagnostics; `acquisition` is `None` when the record has no such field. +fn note_pending( + items: &mut Vec, + subject: &str, + qualification: Qualification, + acquisition: Option>, + pins: &[PinField<'_>], +) { + if qualification != Qualification::Qualified { + items.push(format!("{subject}: qualification pending")); + } + if acquisition.is_some_and(|value| value.is_none()) { + items.push(format!("{subject}: acquisition is null")); + } + for pin in pins.iter().filter(|pin| pin.value.is_none()) { + items.push(format!("{subject}: {} is null", pin.name)); + } +} + +fn expected_tools() -> BTreeMap)> { + let mut expected = BTreeMap::new(); + expected.insert( + ATTEST_NAME.to_owned(), + (ATTEST_VERSION, Some(ATTEST_REVISION)), + ); + for (tool, version) in PER_HOST_TOOLS { + for host in TOOL_HOSTS { + expected.insert(format!("{tool}:{host}"), (version, None)); + } + } + expected +} + +/// Compares parsed pins, environments and recipes with the compiled contract. +pub(super) fn check_tools(tools: &ToolsDocument) -> Result<(), Report> { + if tools.schema_version != SCHEMA_VERSION { + return Err(rejected("tools schema_version must be 1")); + } + let fixed = expected_tools(); + let mut seen = BTreeSet::new(); + for tool in &tools.tools { + if !seen.insert(tool.name.as_str()) { + return Err(rejected(format!("duplicate tool {}", tool.name))); + } + let (version, revision) = *fixed + .get(&tool.name) + .ok_or_else(|| rejected(format!("unknown tool {}", tool.name)))?; + if tool.version != version || tool.revision.as_deref() != revision { + return Err(rejected(format!( + "tool {} must pin version {version} and revision {revision:?}", + tool.name + ))); + } + check_digest( + &format!("tool {}", tool.name), + "sha256", + tool.sha256.as_deref(), + )?; + } + if let Some(missing) = fixed.keys().find(|name| !seen.contains(name.as_str())) { + return Err(rejected(format!("missing tool {missing}"))); + } + check_environments(&tools.environments)?; + check_recipes(&tools.recipes) +} + +fn check_digest( + subject: &str, + field: &str, + value: Option<&str>, +) -> Result<(), Report> { + match value { + Some(digest) if !lower_hex(digest, 64) => Err(rejected(format!( + "{subject}: {field} must be 64 lowercase hex digits or null" + ))), + _ => Ok(()), + } +} + +fn check_environments(environments: &[Environment]) -> Result<(), Report> { + let mut seen = BTreeSet::new(); + for environment in environments { + let identity = environment.identity(); + let id = identity.0; + if !seen.insert(id) { + return Err(rejected(format!("duplicate environment {id}"))); + } + let fixed = FIXED_ENVIRONMENTS + .iter() + .find(|fixed| fixed.0 == id) + .ok_or_else(|| rejected(format!("unknown environment {id}")))?; + if (fixed.1, fixed.2, fixed.3) != (identity.1, identity.2, identity.3) { + return Err(rejected(format!( + "environment {id} changes its fixed kind, role or platform constant" + ))); + } + for pin in environment.pins().iter().filter(|pin| pin.digest) { + check_digest(&format!("environment {id}"), pin.name, pin.value)?; + } + } + if let Some(missing) = FIXED_ENVIRONMENTS + .iter() + .find(|fixed| !seen.contains(fixed.0)) + { + return Err(rejected(format!("missing environment {}", missing.0))); + } + Ok(()) +} + +/// One source-owned recipe identity and the settings it must restate. +struct FixedRecipe { + id: &'static str, + environment: &'static str, + script: &'static str, + settings: Value, +} + +/// Complete effective settings shared by every executable recipe. +fn artifact_settings() -> Value { + json!({ + "ambient_profiles": AmbientProfile::tracked_json(), + "aws_lc_target_selector": true, + "aws_lc_use_system": false, + "cargo_config": ".cargo/config.toml", + "cargo_frozen": true, + "cargo_home": "private-prepared", + "cargo_locked": true, + "cargo_message_format": "json-render-diagnostics", + "download_control": "actual-cargo-build-script-required", + "fresh_target": true, + "network_boundary": "source-owned-os-denial-v1", + "normalized_inputs": [ + "compiler-locale-C", + "compiler-timezone-UTC", + "source-commit-epoch", + "remapped-paths", + ], + "npm_lifecycle": "reviewed-preparation-only", + "release_debug": 1, + "release_strip": "none", + "rust_toolchain": RUST_VERSION, + "sqlite_features": ["bundled"], + "sqlite_use_pkg_config": false, + "sysroot": "authenticated-sdk-required", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production", + }) +} + +/// Complete effective settings of the shared JS recipe. +fn js_settings() -> Value { + json!({ + "compare": "all-tracked-final-module-bytes", + "fresh_installations": 2, + "fresh_private_npm_caches": true, + "fresh_source": "tracked-objects-only", + "mandatory_tests": true, + "network_boundary": "source-owned-os-denial-v1", + "node_version": NODE_VERSION, + "npm_audit": false, + "npm_fund": false, + "npm_include_dev": true, + "npm_lifecycle": "reviewed-observed-foreground", + "npm_offline": true, + "npm_version": NPM_VERSION, + "publish": "A-only-after-equality", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production", + }) +} + +fn expected_recipes() -> Vec { + let mut recipes: Vec<_> = FIXED_ARTIFACTS + .iter() + .map(|fixed| FixedRecipe { + id: fixed.name, + environment: fixed.recipe_environment, + script: ARTIFACT_SCRIPT, + settings: artifact_settings(), + }) + .collect(); + recipes.push(FixedRecipe { + id: JS_RECIPE, + environment: BUILDER_ENVIRONMENT, + script: JS_SCRIPT, + settings: js_settings(), + }); + recipes +} + +/// Names the first settings key whose presence or value differs. +fn first_settings_difference(expected: &Value, actual: &Map) -> Option { + let expected = expected.as_object()?; + expected + .keys() + .chain(actual.keys()) + .find(|key| expected.get(*key) != actual.get(*key)) + .cloned() +} + +fn check_recipes(recipes: &[Recipe]) -> Result<(), Report> { + let expected = expected_recipes(); + let mut seen = BTreeSet::new(); + for recipe in recipes { + let id = recipe.id.as_str(); + if !seen.insert(id) { + return Err(rejected(format!("duplicate recipe {id}"))); + } + let FixedRecipe { + environment, + script, + settings, + .. + } = expected + .iter() + .find(|fixed| fixed.id == id) + .ok_or_else(|| rejected(format!("unknown recipe {id}")))?; + if recipe.environment_id != *environment { + return Err(rejected(format!( + "recipe {id} environment_id must be {environment}" + ))); + } + if recipe.script != *script { + return Err(rejected(format!("recipe {id} script must be {script}"))); + } + if let Some(key) = first_settings_difference(settings, &recipe.settings) { + return Err(rejected(format!( + "recipe {id} settings.{key} differs from the compiled recipe settings" + ))); + } + check_digest(&format!("recipe {id}"), "sha256", recipe.sha256.as_deref())?; + } + if let Some(missing) = expected.iter().find(|fixed| !seen.contains(fixed.id)) { + return Err(rejected(format!("missing recipe {}", missing.id))); + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/preflight.rs b/crates/trusted-server-release/src/preflight.rs new file mode 100644 index 000000000..cf8f907cf --- /dev/null +++ b/crates/trusted-server-release/src/preflight.rs @@ -0,0 +1,314 @@ +//! Inherited-environment audit and allowlisted child-environment construction. +//! +//! The audit runs over the complete inherited snapshot before anything is +//! cleared, so an empty starting environment cannot hide a rejected channel. +//! Errors name variables but never print their values, which may be secrets. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; + +use error_stack::Report; + +use crate::error::ReleaseError; +use crate::policy; +use crate::source::{self, CommitId}; + +const RELEASE_BUILD: &str = "TRUSTED_SERVER__RELEASE_BUILD"; +const REMOVED_PREFIXES: [&str; 2] = ["TRUSTED_SERVER__", "EDGEZERO__"]; + +/// Exact (uppercase) names that select compilers, flags, loaders, sources or +/// credentials. Sorted, unique, and never covered by [`REJECTED_PREFIXES`] or +/// [`NATIVE_SELECTORS`]. +const REJECTED_EXACT: &[&str] = &[ + "ACTIONS_CACHE_URL", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_RESULTS_URL", + "ACTIONS_RUNTIME_TOKEN", + "ACTIONS_RUNTIME_URL", + "BASH_ENV", + "CARGO", + "CLANG_PATH", + "CRATE_CC_NO_DEFAULTS", + "CROSS_COMPILE", + "DEVELOPER_DIR", + "ENV", + "GH_ENTERPRISE_TOKEN", + "GH_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GITHUB_TOKEN", + "LIBCLANG_PATH", + "LLVM_CONFIG_PATH", + "MACOSX_DEPLOYMENT_TARGET", + "NASM", + "NODE_AUTH_TOKEN", + "NODE_ENV", + "NODE_EXTRA_CA_CERTS", + "NODE_OPTIONS", + "NODE_PATH", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NPM_TOKEN", + "PKG_CONFIG", + "PROTOC", + "PROTOC_INCLUDE", + "RUSTC", + "RUSTDOC", + "RUSTDOCFLAGS", + "RUSTFLAGS", + "SDKROOT", + "SOURCE_DATE_EPOCH", + "SSH_AUTH_SOCK", + "TSJS_SKIP_BUILD", + "VCPKG_INSTALLATION_ROOT", + "VCPKG_ROOT", +]; + +/// Uppercase prefixes whose whole family is recipe-selected or forbidden. +const REJECTED_PREFIXES: &[&str] = &[ + "AWS_", + "BINDGEN_", + "CARGO_", + "CLOUDFLARE_", + "CMAKE", + "DYLD_", + "FASTLY_", + "GIT_", + "LD_", + "LIBSQLITE3_", + "NPM_CONFIG_", + "OPENSSL_", + "PKG_CONFIG_", + "RING_", + "RUSTC_", + "RUSTDOC_", + "RUSTUP_", + "SIGSTORE_", + "SQLCIPHER_", + "SQLITE3_", + "SQLITE_", + "VCPKGRS_", + "VITE_", +]; + +/// Native tool selectors; `_`, `-`, `TARGET_` and `HOST_` variants are rejected too. +const NATIVE_SELECTORS: [&str; 11] = [ + "AR", + "ARFLAGS", + "CC", + "CFLAGS", + "CPPFLAGS", + "CXX", + "CXXFLAGS", + "CXXSTDLIB", + "LD", + "LDFLAGS", + "RANLIB", +]; + +/// A hosted setup export accepted only for an exact value whose path resolves to a +/// mandatory reviewed identity; value-only exceptions are not representable. +pub(crate) struct ReviewedException { + name: &'static str, + value: &'static str, + path_identity: &'static str, +} + +/// Reviewed hosted setup values. Empty until values and paths are qualified. +pub(crate) const REVIEWED_SETUP_EXCEPTIONS: [ReviewedException; 0] = []; + +/// Proof that the inherited environment snapshot passed the release audit. +pub(crate) struct AuditedEnvironment { + removed: Vec, + search_path: Option, +} + +impl AuditedEnvironment { + /// Names of inherited operator variables removed rather than passed on. + pub(crate) fn removed_names(&self) -> &[String] { + &self.removed + } + + /// Inherited `PATH`, used only to locate preflight helpers such as Git. + pub(crate) fn search_path(&self) -> Option<&str> { + self.search_path.as_deref() + } +} + +/// Audits an inherited environment snapshot collected before any clearing. +/// +/// # Errors +/// +/// Fails when any variable is non-UTF-8, selects a rejected channel, or sets +/// `TRUSTED_SERVER__RELEASE_BUILD` to a value other than `1`. The error lists +/// every rejected name and never a value. +pub(crate) fn audit_environment( + snapshot: &[(OsString, OsString)], +) -> Result> { + let mut rejected = BTreeSet::new(); + let mut removed = BTreeSet::new(); + let mut search_path = None; + for (name, value) in snapshot { + let Some(name) = name.to_str() else { + rejected.insert(format!("{} (non-UTF-8 name)", name.to_string_lossy())); + continue; + }; + let Some(value) = value.to_str() else { + rejected.insert(format!("{name} (non-UTF-8 value)")); + continue; + }; + if name == RELEASE_BUILD && value != "1" { + rejected.insert(format!("{name} (must be exactly 1 when present)")); + } else if REMOVED_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) + { + removed.insert(name.to_owned()); + } else if REMOVED_PREFIXES + .iter() + .any(|prefix| name.to_ascii_uppercase().starts_with(prefix)) + { + rejected.insert(format!("{name} (ambiguous operator prefix casing)")); + } else if is_rejected(name) && !is_reviewed(name, value) { + rejected.insert(name.to_owned()); + } else if name == "PATH" { + search_path = Some(value.to_owned()); + } + } + if !rejected.is_empty() { + return Err(Report::new(ReleaseError::Environment).attach(format!( + "rejected inherited variables: {}", + rejected.into_iter().collect::>().join(", ") + ))); + } + Ok(AuditedEnvironment { + removed: removed.into_iter().collect(), + search_path, + }) +} + +fn is_rejected(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + REJECTED_EXACT.contains(&upper.as_str()) + || REJECTED_PREFIXES + .iter() + .any(|prefix| upper.starts_with(prefix)) + || NATIVE_SELECTORS + .iter() + .any(|selector| is_native_variant(&upper, selector)) +} + +fn is_native_variant(upper: &str, selector: &str) -> bool { + let base = upper + .strip_prefix("TARGET_") + .or_else(|| upper.strip_prefix("HOST_")) + .unwrap_or(upper); + base == selector + || upper + .strip_prefix(selector) + .is_some_and(|suffix| suffix.starts_with(['_', '-'])) +} + +impl ReviewedException { + /// Accepts only the exact name and value whose path resolves to the reviewed identity. + fn matches(&self, name: &str, value: &str) -> bool { + self.name == name + && self.value == value + && std::fs::canonicalize(value).is_ok_and(|path| path == Path::new(self.path_identity)) + } +} + +fn is_reviewed(name: &str, value: &str) -> bool { + REVIEWED_SETUP_EXCEPTIONS + .iter() + .any(|exception| exception.matches(name, value)) +} + +/// Resolves the source root, which is always the current directory. +/// +/// # Errors +/// +/// Fails when the current directory cannot be resolved. +pub(crate) fn source_root() -> Result> { + std::env::current_dir().map_err(|error| { + Report::new(ReleaseError::Source).attach(format!("cannot resolve source root: {error}")) + }) +} + +fn names_cohort(root: &Path, cohort: &Path) -> bool { + !cohort.as_os_str().is_empty() + && !cohort + .components() + .any(|component| matches!(component, Component::ParentDir)) + && root + .join(cohort) + .components() + .eq(root.join(policy::COHORT_PATH).components()) +} + +/// Runs the complete fail-closed preflight over the current-directory source. +/// +/// The qualified selector always runs, so omitting `--require-qualified` +/// can never succeed where passing it would fail. +/// +/// # Errors +/// +/// Fails on any rejected inherited variable, a cohort path other than the +/// source-owned one, unauthenticated source, inputs that differ from the +/// approved commit, or the closed qualified selector. +pub(crate) fn run_preflight( + inherited: &[(OsString, OsString)], + cohort: &Path, + commit: &CommitId, + require_qualified: bool, +) -> Result<(), Report> { + let audited = audit_environment(inherited)?; + if !audited.removed_names().is_empty() { + crate::notice(format_args!( + "removed inherited operator variables: {}", + audited.removed_names().join(", ") + )); + } + let root = source_root()?; + if !names_cohort(&root, cohort) { + return Err(Report::new(ReleaseError::Policy).attach(format!( + "--cohort must name {} below the current source root", + policy::COHORT_PATH + ))); + } + let source = source::authenticate(&root, commit, &audited)?; + let inputs = policy::load_inputs(&root)?; + for (path, sha256) in inputs.input_digests() { + if source.tracked_sha256(path) != Some(sha256) { + return Err(Report::new(ReleaseError::Source) + .attach(format!("{path} does not match the approved commit"))); + } + } + crate::notice(format_args!( + "source matched locally; git executable and remote eligibility not established \ + (commit {}, epoch {}, snapshot {}, helper closure {} over {} paths; \ + git executable identity: Task 8; remote branch-tip ancestry and rulesets: Task 14)", + source.commit(), + source.epoch(), + source.snapshot().fingerprint(), + source.closure_digest(), + source.closure_paths().len() + )); + if !require_qualified { + crate::notice(format_args!( + "qualified selection is required whether or not --require-qualified is passed" + )); + } + let selection = policy::select_qualified(&inputs, &[])?; + source.snapshot().verify_unchanged()?; + crate::notice(format_args!("{}", selection.summary())); + Ok(()) +} + +// Recipe execution (Tasks 5 and 7) is the only production consumer of the +// child-environment builder; until it lands, only tests reference it. +#[allow(dead_code)] +pub(crate) mod child; + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/preflight/child.rs b/crates/trusted-server-release/src/preflight/child.rs new file mode 100644 index 000000000..29a0d7778 --- /dev/null +++ b/crates/trusted-server-release/src/preflight/child.rs @@ -0,0 +1,157 @@ +//! Allowlisted child-environment construction for recipe execution. +//! +//! The child environment is built from scratch: nothing inherited passes +//! through, and only recipe-selected facts and private locations are set. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use super::{AuditedEnvironment, RELEASE_BUILD}; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; +use crate::source::{ApprovedSource, CommitId}; + +const MACOS_DEPLOYMENT_TARGET: &str = "13.0"; + +/// A tool directory produced by authenticated acquisition (Task 8). +pub(crate) struct ToolDirectory(pub(super) PathBuf); + +/// Recipe-selected private locations for one child build. +pub(crate) struct ChildRequest { + pub(crate) target: ReleaseTarget, + pub(crate) home: PathBuf, + pub(crate) cargo_home: PathBuf, + pub(crate) tool_directories: Vec, +} + +/// An allowlisted child environment constructed from scratch. +pub(crate) struct ChildEnvironment { + variables: BTreeMap, + removed: Vec, +} + +impl ChildEnvironment { + /// Every variable the child receives; nothing is inherited. + pub(crate) fn variables(&self) -> &BTreeMap { + &self.variables + } + + /// Sorted names injected by the recipe, recorded as input facts. + pub(crate) fn injected_names(&self) -> Vec<&str> { + self.variables.keys().map(String::as_str).collect() + } + + /// Inherited operator names removed during the audit. + pub(crate) fn removed_names(&self) -> &[String] { + &self.removed + } +} + +fn absolute_text<'a>(label: &str, path: &'a Path) -> Result<&'a str, Report> { + path.to_str() + .filter(|text| path.is_absolute() && !text.contains([':', '\0'])) + .ok_or_else(|| { + Report::new(ReleaseError::Environment).attach(format!( + "{label} must be an absolute UTF-8 path without ':' or NUL" + )) + }) +} + +/// Joins authenticated tool directories into a child `PATH`, if any are given. +fn tool_path(directories: &[ToolDirectory]) -> Result, Report> { + if directories.is_empty() { + return Ok(None); + } + let entries = directories + .iter() + .map(|directory| absolute_text("tool directory", &directory.0)) + .collect::, _>>()?; + Ok(Some(entries.join(":"))) +} + +/// Builds the child environment from explicit approved facts. +pub(super) fn build_child( + audited: &AuditedEnvironment, + commit: &CommitId, + epoch: u64, + request: &ChildRequest, +) -> Result> { + let mut variables = BTreeMap::from([ + ("AWS_LC_SYS_USE_SYSTEM".to_owned(), "0".to_owned()), + ( + format!( + "AWS_LC_SYS_USE_SYSTEM_{}", + request.target.triple().replace('-', "_") + ), + "0".to_owned(), + ), + ( + "CARGO_HOME".to_owned(), + absolute_text("CARGO_HOME", &request.cargo_home)?.to_owned(), + ), + ( + "HOME".to_owned(), + absolute_text("HOME", &request.home)?.to_owned(), + ), + ("LC_ALL".to_owned(), "C".to_owned()), + ("LIBSQLITE3_SYS_USE_PKG_CONFIG".to_owned(), "0".to_owned()), + ("SOURCE_DATE_EPOCH".to_owned(), epoch.to_string()), + ( + "TRUSTED_SERVER__GIT_VERSION".to_owned(), + commit.as_str().to_owned(), + ), + (RELEASE_BUILD.to_owned(), "1".to_owned()), + ("TZ".to_owned(), "UTC".to_owned()), + ]); + if let Some(path) = tool_path(&request.tool_directories)? { + variables.insert("PATH".to_owned(), path); + } + if request.target.is_darwin() { + variables.insert( + "MACOSX_DEPLOYMENT_TARGET".to_owned(), + MACOS_DEPLOYMENT_TARGET.to_owned(), + ); + } + Ok(ChildEnvironment { + variables, + removed: audited.removed.clone(), + }) +} + +/// Builds the allowlisted child environment for one recipe execution. +/// +/// This is the production entry point: it requires both the audited +/// inherited environment and the locally authenticated source, so the +/// injected commit and committer epoch can only come from authentication. +/// +/// # Errors +/// +/// Fails when a private location or tool directory is not an absolute UTF-8 +/// path without `:` or NUL. +pub(crate) fn child_environment( + audited: &AuditedEnvironment, + source: &ApprovedSource, + request: &ChildRequest, +) -> Result> { + build_child(audited, source.commit(), source.epoch(), request) +} + +/// Builds a child environment from explicit facts, for recipe tests only. +/// +/// Production code must go through [`child_environment`], which takes the +/// commit and epoch from an [`ApprovedSource`]. +/// +/// # Errors +/// +/// As [`child_environment`]. +#[cfg(test)] +pub(crate) fn build_child_for_tests( + audited: &AuditedEnvironment, + commit: &CommitId, + epoch: u64, + request: &ChildRequest, +) -> Result> { + build_child(audited, commit, epoch, request) +} diff --git a/crates/trusted-server-release/src/preflight/tests.rs b/crates/trusted-server-release/src/preflight/tests.rs new file mode 100644 index 000000000..89209e552 --- /dev/null +++ b/crates/trusted-server-release/src/preflight/tests.rs @@ -0,0 +1,648 @@ +use std::os::unix::ffi::OsStringExt as _; + +use std::collections::BTreeMap; + +use super::child::{ChildRequest, ToolDirectory, build_child}; +use super::*; +use crate::policy::ReleaseTarget; + +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn commit() -> CommitId { + CommitId::parse(COMMIT).expect("should parse fictional commit") +} + +fn snapshot(entries: &[(&str, &str)]) -> Vec<(OsString, OsString)> { + entries + .iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect() +} + +fn rejection(entries: &[(&str, &str)]) -> String { + let report = audit_environment(&snapshot(entries)) + .err() + .expect("should reject inherited environment"); + format!("{report:?}") +} + +fn request(target: ReleaseTarget) -> ChildRequest { + ChildRequest { + target, + home: PathBuf::from("/private/release/home"), + cargo_home: PathBuf::from("/private/release/cargo-home"), + tool_directories: vec![ + ToolDirectory(PathBuf::from("/private/release/tools/rust/bin")), + ToolDirectory(PathBuf::from("/private/release/tools/node/bin")), + ], + } +} + +#[test] +fn accepts_benign_environment_and_keeps_search_path() { + let audited = audit_environment(&snapshot(&[ + ("PATH", "/usr/bin:/bin"), + ("HOME", "/home/fictional"), + ("LANG", "en_US.UTF-8"), + ("TERM", "xterm"), + ])) + .expect("should accept benign variables"); + assert_eq!(audited.search_path(), Some("/usr/bin:/bin")); + assert!(audited.removed_names().is_empty(), "should remove nothing"); +} + +#[test] +fn rejects_tsjs_skip_build_with_empty_value() { + let text = rejection(&[("TSJS_SKIP_BUILD", "")]); + assert!(text.contains("TSJS_SKIP_BUILD"), "should name skip flag"); +} + +#[test] +fn rejects_tsjs_skip_build_with_zero_value() { + let text = rejection(&[("TSJS_SKIP_BUILD", "0")]); + assert!(text.contains("TSJS_SKIP_BUILD"), "should name skip flag"); +} + +#[test] +fn rejects_tsjs_skip_build_with_one_value() { + let text = rejection(&[("TSJS_SKIP_BUILD", "1")]); + assert!(text.contains("TSJS_SKIP_BUILD"), "should name skip flag"); +} + +fn assert_each_rejected(class: &str, names: &[&str]) { + for name in names { + let text = rejection(&[("PATH", "/usr/bin"), (name, "fictional-value")]); + assert!( + text.contains(name), + "should reject {class} variable {name}: {text}" + ); + assert!( + !text.contains("fictional-value"), + "should never print {class} values" + ); + } +} + +#[test] +fn rejects_compiler_wrappers() { + assert_each_rejected( + "wrapper", + &[ + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "RUSTC", + "RUSTDOC", + ], + ); +} + +#[test] +fn rejects_compiler_and_profile_flags() { + assert_each_rejected( + "flag", + &[ + "RUSTFLAGS", + "RUSTDOCFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_ENCODED_RUSTDOCFLAGS", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_TARGET_WASM32_WASIP1_LINKER", + "CARGO_TARGET_DIR", + "CARGO_PROFILE_RELEASE_DEBUG", + ], + ); +} + +#[test] +fn rejects_cargo_and_rustup_configuration_layers() { + assert_each_rejected( + "cargo-config", + &[ + "CARGO", + "CARGO_HOME", + "CARGO_NET_OFFLINE", + "CARGO_MANIFEST_DIR", + "RUSTUP_HOME", + "RUSTUP_TOOLCHAIN", + ], + ); +} + +#[test] +fn rejects_node_npm_and_vite_overrides() { + assert_each_rejected( + "node", + &[ + "NODE_OPTIONS", + "NODE_PATH", + "npm_config_registry", + "NPM_CONFIG_CACHE", + "Npm_Config_Ignore_Scripts", + "VITE_MODE", + ], + ); +} + +#[test] +fn rejects_native_build_selectors_and_target_variants() { + assert_each_rejected( + "native", + &[ + "AWS_LC_SYS_USE_SYSTEM", + "AWS_LC_SYS_CMAKE_BUILDER", + "AWS_LC_SYS_PREBUILT_NASM_x86_64_unknown_linux_gnu", + "AWS_LC_FIPS_SYS_STATIC", + "LIBSQLITE3_SYS_USE_PKG_CONFIG", + "SQLITE3_LIB_DIR", + "CC", + "CXX", + "AR", + "LD", + "CFLAGS", + "CXXFLAGS", + "LDFLAGS", + "CPPFLAGS", + "CC_x86_64_unknown_linux_gnu", + "CFLAGS_aarch64-apple-darwin", + "TARGET_CC", + "HOST_CFLAGS", + "PKG_CONFIG", + "PKG_CONFIG_PATH", + "BINDGEN_EXTRA_CLANG_ARGS", + "SDKROOT", + "DEVELOPER_DIR", + "MACOSX_DEPLOYMENT_TARGET", + "VCPKG_INSTALLATION_ROOT", + "VCPKG_ROOT", + ], + ); +} + +#[test] +fn rejects_sqlite_selectors() { + assert_each_rejected( + "sqlite", + &[ + "LIBSQLITE3_FLAGS", + "SQLITE_MAX_VARIABLE_NUMBER", + "SQLCIPHER_LIB_DIR", + ], + ); +} + +#[test] +fn rejects_ring_and_openssl_selectors() { + assert_each_rejected( + "crypto", + &[ + "RING_PREGENERATE_ASM", + "OPENSSL_DIR", + "OPENSSL_STATIC", + "OPENSSL_CONF", + "OPENSSL_CONF_INCLUDE", + ], + ); +} + +#[test] +fn rejects_cmake_selectors() { + assert_each_rejected( + "cmake", + &["CMAKE", "CMAKE_TOOLCHAIN_FILE", "CMAKE_GENERATOR"], + ); +} + +#[test] +fn rejects_vcpkg_rs_selectors() { + assert_each_rejected("vcpkg", &["VCPKGRS_DYNAMIC", "VCPKGRS_TRIPLET"]); +} + +#[test] +fn rejects_cc_crate_controls() { + assert_each_rejected( + "cc-crate", + &[ + "CRATE_CC_NO_DEFAULTS", + "CC_ENABLE_DEBUG_OUTPUT", + "CC_FORCE_DISABLE", + "CC_SHELL_ESCAPED_FLAGS", + ], + ); +} + +#[test] +fn rejects_lowercase_target_suffixed_native_flags() { + assert_each_rejected( + "target-suffixed", + &[ + "CFLAGS_aarch64_apple_darwin", + "CXXFLAGS_x86_64_unknown_linux_gnu", + "AWS_LC_SYS_USE_SYSTEM_aarch64_apple_darwin", + "AWS_LC_SYS_STATIC_x86_64_apple_darwin", + ], + ); +} + +#[test] +fn rejects_loader_injection() { + assert_each_rejected( + "loader", + &[ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + ], + ); +} + +#[test] +fn rejects_git_channels_and_source_date_epoch() { + assert_each_rejected( + "git", + &[ + "GIT_DIR", + "GIT_CONFIG_PARAMETERS", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "SOURCE_DATE_EPOCH", + ], + ); +} + +#[test] +fn rejects_credentials_and_signing_material() { + assert_each_rejected( + "credential", + &[ + "GH_TOKEN", + "GITHUB_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_RUNTIME_TOKEN", + "FASTLY_API_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "SIGSTORE_ID_TOKEN", + ], + ); +} + +#[test] +fn rejects_unreviewed_setup_exports_even_when_empty() { + assert!( + REVIEWED_SETUP_EXCEPTIONS.is_empty(), + "should have no reviewed hosted setup values yet" + ); + for (name, value) in [ + ("RUSTFLAGS", ""), + ("VCPKG_INSTALLATION_ROOT", "/usr/local/share/vcpkg"), + ] { + let text = rejection(&[(name, value)]); + assert!(text.contains(name), "should reject unreviewed {name}"); + } +} + +#[test] +fn rejects_non_utf8_names_and_values() { + let bad_value = vec![( + OsString::from("FICTIONAL_SETTING"), + OsString::from_vec(vec![0x66, 0xff, 0x6f]), + )]; + let text = format!( + "{:?}", + audit_environment(&bad_value) + .err() + .expect("should reject non-UTF-8 value") + ); + assert!(text.contains("FICTIONAL_SETTING"), "should name variable"); + let bad_name = vec![( + OsString::from_vec(vec![0x46, 0xff, 0x4f]), + OsString::from("value"), + )]; + assert!( + audit_environment(&bad_name).is_err(), + "should reject non-UTF-8 name" + ); +} + +#[test] +fn rejects_release_build_flag_other_than_one() { + for value in ["0", "", "true", "11"] { + let text = rejection(&[("TRUSTED_SERVER__RELEASE_BUILD", value)]); + assert!( + text.contains("TRUSTED_SERVER__RELEASE_BUILD"), + "should reject release flag value" + ); + } +} + +#[test] +fn removes_operator_prefixes_by_full_prefix() { + let audited = audit_environment(&snapshot(&[ + ("TRUSTED_SERVER__RELEASE_BUILD", "1"), + ("TRUSTED_SERVER__PUBLISHER__DOMAIN", "publisher.example.com"), + ("EDGEZERO__STORES__CONFIG__ID", "fictional"), + ("MY_TRUSTED_SERVER__SETTING", "kept-out"), + ("TRUSTED_SERVERX", "kept-out"), + ])) + .expect("should accept removable operator variables"); + assert_eq!( + audited.removed_names(), + [ + "EDGEZERO__STORES__CONFIG__ID", + "TRUSTED_SERVER__PUBLISHER__DOMAIN", + "TRUSTED_SERVER__RELEASE_BUILD", + ], + "should record exactly the case-exact prefixed names" + ); +} + +#[test] +fn reports_every_rejected_name_without_secret_values() { + let text = rejection(&[ + ("GH_TOKEN", "ghp_fictionalsecretvalue"), + ("AWS_SECRET_ACCESS_KEY", "fictional/secret/access/key"), + ("RUSTFLAGS", "-C link-arg=fictional-secret-flag"), + ("TSJS_SKIP_BUILD", "1"), + ]); + for name in [ + "GH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "RUSTFLAGS", + "TSJS_SKIP_BUILD", + ] { + assert!(text.contains(name), "should name {name}"); + } + for secret in [ + "ghp_fictionalsecretvalue", + "fictional/secret/access/key", + "fictional-secret-flag", + ] { + assert!(!text.contains(secret), "should never print variable values"); + } +} + +#[test] +fn child_environment_contains_exactly_expected_linux_keys() { + let audited = audit_environment(&snapshot(&[ + ("PATH", "/usr/bin"), + ("TRUSTED_SERVER__PROXY__SECRET", "fictional"), + ("LANG", "en_US.UTF-8"), + ])) + .expect("should audit benign environment"); + let child = build_child( + &audited, + &commit(), + 1_700_000_000, + &request(ReleaseTarget::X86_64UnknownLinuxGnu), + ) + .expect("should build child environment"); + let expected = BTreeMap::from([ + ("AWS_LC_SYS_USE_SYSTEM", "0"), + ("AWS_LC_SYS_USE_SYSTEM_x86_64_unknown_linux_gnu", "0"), + ("CARGO_HOME", "/private/release/cargo-home"), + ("HOME", "/private/release/home"), + ("LC_ALL", "C"), + ("LIBSQLITE3_SYS_USE_PKG_CONFIG", "0"), + ( + "PATH", + "/private/release/tools/rust/bin:/private/release/tools/node/bin", + ), + ("SOURCE_DATE_EPOCH", "1700000000"), + ("TRUSTED_SERVER__GIT_VERSION", COMMIT), + ("TRUSTED_SERVER__RELEASE_BUILD", "1"), + ("TZ", "UTC"), + ]); + let actual: BTreeMap<&str, &str> = child + .variables() + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect(); + assert_eq!( + actual, expected, + "should construct the allowlist from scratch" + ); + assert_eq!( + child.injected_names(), + expected.keys().copied().collect::>(), + "should record injected names as input facts" + ); + assert_eq!( + child.removed_names(), + ["TRUSTED_SERVER__PROXY__SECRET"], + "should record removed operator names" + ); +} + +#[test] +fn child_environment_sets_macos_floor_only_for_darwin() { + let audited = audit_environment(&[]).expect("should audit empty snapshot"); + for target in [ + ReleaseTarget::Aarch64AppleDarwin, + ReleaseTarget::X86_64AppleDarwin, + ] { + let child = build_child(&audited, &commit(), 1, &request(target)) + .expect("should build darwin child"); + assert_eq!( + child + .variables() + .get("MACOSX_DEPLOYMENT_TARGET") + .map(String::as_str), + Some("13.0"), + "should set loader floor for {target:?}" + ); + } + for target in [ + ReleaseTarget::Wasm32Wasip1, + ReleaseTarget::X86_64UnknownLinuxGnu, + ] { + let child = build_child(&audited, &commit(), 1, &request(target)) + .expect("should build non-darwin child"); + assert!( + !child.variables().contains_key("MACOSX_DEPLOYMENT_TARGET"), + "should not set macOS floor for {target:?}" + ); + } +} + +#[test] +fn child_environment_rejects_unsafe_paths_and_commits() { + let audited = audit_environment(&[]).expect("should audit empty snapshot"); + let mut relative = request(ReleaseTarget::Wasm32Wasip1); + relative.home = PathBuf::from("home"); + assert!( + build_child(&audited, &commit(), 1, &relative).is_err(), + "should reject relative HOME" + ); + let mut colon = request(ReleaseTarget::Wasm32Wasip1); + colon.tool_directories = vec![ToolDirectory(PathBuf::from("/tools:/usr/bin"))]; + assert!( + build_child(&audited, &commit(), 1, &colon).is_err(), + "should reject PATH separator inside a tool directory" + ); + for malformed in ["ABC", "--help", &COMMIT.to_uppercase(), &COMMIT[..39]] { + assert!( + CommitId::parse(malformed).is_err(), + "should reject malformed commit {malformed}" + ); + } +} + +#[test] +fn child_environment_sets_target_suffixed_aws_lc_selector() { + let audited = audit_environment(&[]).expect("should audit empty snapshot"); + for (target, name) in [ + ( + ReleaseTarget::Wasm32Wasip1, + "AWS_LC_SYS_USE_SYSTEM_wasm32_wasip1", + ), + ( + ReleaseTarget::Aarch64AppleDarwin, + "AWS_LC_SYS_USE_SYSTEM_aarch64_apple_darwin", + ), + ( + ReleaseTarget::X86_64AppleDarwin, + "AWS_LC_SYS_USE_SYSTEM_x86_64_apple_darwin", + ), + ] { + let child = build_child(&audited, &commit(), 1, &request(target)) + .expect("should build child environment"); + assert_eq!( + child.variables().get(name).map(String::as_str), + Some("0"), + "should pin the target-suffixed selector for {target:?}" + ); + assert_eq!( + child + .variables() + .keys() + .filter(|key| key.starts_with("AWS_LC_SYS_USE_SYSTEM")) + .count(), + 2, + "should set only the generic and selected-target selectors" + ); + } +} + +#[test] +fn rejects_ambiguous_case_operator_prefixes() { + for name in [ + "trusted_server__lowercase", + "Trusted_Server__Mixed", + "edgezero__stores", + "EdgeZero__Stores", + ] { + let text = rejection(&[(name, "fictional-value")]); + assert!(text.contains(name), "should reject ambiguous casing {name}"); + } +} + +#[test] +fn rejects_package_registry_and_cloud_credentials() { + assert_each_rejected( + "registry-credential", + &[ + "NPM_TOKEN", + "NODE_AUTH_TOKEN", + "AWS_REGION", + "AWS_PROFILE", + "AWS_CONFIG_FILE", + "SSH_AUTH_SOCK", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_ACCOUNT_ID", + ], + ); +} + +#[test] +fn rejects_hosted_runner_service_endpoints() { + assert_each_rejected( + "runner-service", + &[ + "ACTIONS_CACHE_URL", + "ACTIONS_RESULTS_URL", + "ACTIONS_RUNTIME_URL", + ], + ); +} + +#[test] +fn rejects_shell_startup_injection() { + assert_each_rejected("shell", &["BASH_ENV", "ENV"]); +} + +#[test] +fn rejects_native_toolchain_locators() { + assert_each_rejected( + "native-locator", + &[ + "CXXSTDLIB", + "CXXSTDLIB_x86_64_unknown_linux_gnu", + "TARGET_CXXSTDLIB", + "CROSS_COMPILE", + "LIBCLANG_PATH", + "CLANG_PATH", + "LLVM_CONFIG_PATH", + "PROTOC", + "PROTOC_INCLUDE", + "NASM", + ], + ); +} + +#[test] +fn rejects_node_trust_and_mode_overrides() { + assert_each_rejected( + "node-trust", + &[ + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_ENV", + ], + ); +} + +#[test] +fn reviewed_exceptions_require_a_path_identity() { + let exception = ReviewedException { + name: "VCPKG_INSTALLATION_ROOT", + value: "/fictional/vcpkg", + path_identity: "/fictional/vcpkg", + }; + assert!( + !exception.matches("VCPKG_INSTALLATION_ROOT", "/fictional/vcpkg"), + "should not accept a value whose path identity cannot be resolved" + ); + assert!( + !exception.matches("VCPKG_ROOT", "/fictional/vcpkg"), + "should not accept a different name" + ); +} + +#[test] +fn rejected_exact_names_are_sorted_unique_and_not_covered_elsewhere() { + assert!( + REJECTED_EXACT.windows(2).all(|pair| pair[0] < pair[1]), + "should keep exact names strictly sorted, which also rules out duplicates" + ); + for name in REJECTED_EXACT { + assert!( + !REJECTED_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)), + "{name} is already covered by a rejected prefix" + ); + assert!( + !NATIVE_SELECTORS + .iter() + .any(|selector| is_native_variant(name, selector)), + "{name} is already covered by a native selector" + ); + } +} diff --git a/crates/trusted-server-release/src/preparation.rs b/crates/trusted-server-release/src/preparation.rs new file mode 100644 index 000000000..d92610175 --- /dev/null +++ b/crates/trusted-server-release/src/preparation.rs @@ -0,0 +1,682 @@ +//! Fresh, independently owned npm installation state and authenticated observations. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use error_stack::{Report, ResultExt as _}; +use hmac::{Hmac, KeyInit as _, Mac as _}; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::boundary::{Boundary, BoundaryRoots, ConfinedCommand, DenialProof, random_bytes}; +#[cfg(test)] +use crate::documents::hash_bounded_with_check; +use crate::documents::{Role, parse_json}; +use crate::error::ReleaseError; +use crate::materials::{LockedMaterials, PreparedNpmOriginals, RootManifestReview}; +use crate::process::{Cancellation, ProcessLimits}; +use crate::source::{ApprovedSource, FreshSource}; +use crate::tool_tree::tool_tree_digest; + +pub(super) const LIB: &str = "crates/trusted-server-js/lib"; +const PREFIX: &[u8] = b"TS_RELEASE_NPM_OBSERVATION_V1 "; + +pub(super) fn preparation_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Material).attach(detail.to_string()) +} + +/// Authenticated and qualified JS tool selections are supplied by Task 3/8. +/// +/// None is qualified yet; ordinary CI fixtures use an explicit test constructor. +#[derive(Clone, Copy)] +pub(crate) enum QualifiedJsTools {} + +/// Fixed Node/npm/guard/probe layout, retained as a measured local candidate. +pub(crate) struct SelectedJsTools { + root: PathBuf, +} + +impl SelectedJsTools { + /// Accepts only a qualified selection; production construction stays closed. + pub(crate) fn qualified(_root: &Path, qualification: QualifiedJsTools) -> Self { + match qualification {} + } + + /// Selects ordinary CI tools without asserting release qualification. + /// + /// # Errors + /// + /// Returns an error for absent or unsupported candidate tool paths. + #[cfg(test)] + pub(crate) fn for_tests(root: &Path) -> Result> { + let root = fs::canonicalize(root).map_err(preparation_error)?; + for relative in [ + "bin/node", + "lib/node_modules/npm/bin/npm-cli.js", + "guard.node", + "probe", + ] { + hash_bounded_with_check(&root.join(relative), 512 * 1024 * 1024, &|| Ok(()))?; + } + Ok(Self { root }) + } + + pub(super) fn node(&self) -> PathBuf { + self.root.join("bin/node") + } + fn npm(&self) -> PathBuf { + self.root.join("lib/node_modules/npm/bin/npm-cli.js") + } + fn guard(&self) -> PathBuf { + self.root.join("guard.node") + } + + fn digest(&self, attempt: &JsAttempt) -> Result> { + tool_tree_digest(&self.root, &|| attempt.check()) + } +} + +/// One shared bounded preparation attempt, independent of inherited environment. +#[derive(Clone)] +pub(crate) struct JsAttempt { + pub(super) deadline: Instant, + pub(super) cancellation: Cancellation, +} + +impl JsAttempt { + /// Creates a bounded attempt using an explicitly selected deadline and cancellation. + pub(crate) fn new(deadline: Instant, cancellation: Cancellation) -> Self { + Self { + deadline, + cancellation, + } + } + pub(super) fn check(&self) -> Result<(), Report> { + if self.cancellation.is_cancelled() || Instant::now() >= self.deadline { + return Err(preparation_error("JS preparation cancelled or timed out")); + } + Ok(()) + } +} + +/// One side has its own source, installation, cache, home, temporary and output roots. +pub(super) struct Side { + pub(super) source: FreshSource, + pub(super) state: tempfile::TempDir, + metadata: tempfile::TempDir, + pub(super) expected_tests: BTreeSet, +} + +impl Side { + fn new( + source: FreshSource, + module_ids: &[String], + attempt: &JsAttempt, + ) -> Result> { + let state = tempfile::tempdir().map_err(preparation_error)?; + let metadata = tempfile::tempdir().map_err(preparation_error)?; + for relative in ["cache", "home", "tmp", "output", "output/bundles"] { + fs::create_dir(state.path().join(relative)).map_err(preparation_error)?; + } + let context = + json!({"schema_version":1,"source_commit":source.commit(),"module_ids":module_ids}); + fs::write( + metadata.path().join("source-context.json"), + serde_json::to_vec(&context).map_err(preparation_error)?, + ) + .map_err(preparation_error)?; + let expected_tests = discover_tests(&source.root().join(LIB), attempt)?; + Ok(Self { + source, + state, + metadata, + expected_tests, + }) + } + pub(super) fn output(&self) -> PathBuf { + self.state.path().join("output") + } + pub(super) fn library(&self) -> PathBuf { + self.source.root().join(LIB) + } + pub(super) fn verify_source(&self) -> Result<(), Report> { + self.source + .verify_unchanged(&["crates/trusted-server-js/lib/node_modules"]) + } +} + +fn discover_tests( + root: &Path, + attempt: &JsAttempt, +) -> Result, Report> { + let mut pending = vec![root.join("test")]; + let mut tests = BTreeSet::new(); + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory).map_err(preparation_error)? { + attempt.check()?; + let entry = entry.map_err(preparation_error)?; + let metadata = fs::symlink_metadata(entry.path()).map_err(preparation_error)?; + if metadata.is_dir() { + pending.push(entry.path()); + } else if metadata.is_file() { + let path = entry.path(); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| preparation_error("test path is not UTF-8"))?; + if [ + ".test.ts", + ".test.mjs", + ".test.js", + ".spec.ts", + ".spec.mjs", + ".spec.js", + ] + .iter() + .any(|suffix| name.ends_with(suffix)) + { + tests.insert( + path.strip_prefix(root) + .map_err(preparation_error)? + .to_str() + .ok_or_else(|| preparation_error("test path is not UTF-8"))? + .to_owned(), + ); + } + } else { + return Err(preparation_error( + "test registration contains an unsafe file type", + )); + } + } + } + if tests.is_empty() || !tests.iter().any(|path| path.ends_with("types.test.ts")) { + return Err(preparation_error( + "full JS and registered type-check suites are required", + )); + } + Ok(tests) +} + +/// Fresh two-side state, without any installation or completion authority yet. +pub(crate) struct PreparedJsBuild<'a> { + pub(super) source: &'a ApprovedSource, + pub(super) originals: &'a PreparedNpmOriginals, + pub(super) tools: SelectedJsTools, + pub(super) tools_digest: String, + pub(super) module_ids: Vec, + pub(super) sides: [Side; 2], + pub(super) attempt: JsAttempt, + declarations: BTreeMap<(String, String), String>, + _root_review: RootManifestReview, +} + +/// Private measured installation data, constructed only after HMAC and joins. +pub(super) struct ObservedInstall { + preparation: Value, + pub(super) raw: Vec, +} + +impl<'a> PreparedJsBuild<'a> { + /// Closes every independent source, installation and metadata root, even after a failure. + /// + /// # Errors + /// + /// Returns all observed cleanup failures; expiration never prevents cleanup. + pub(super) fn cleanup(self) -> Result<(), Report> { + let mut failure = None::>; + for side in self.sides { + for result in [ + side.source.close(), + side.state.close().map_err(preparation_error), + side.metadata.close().map_err(preparation_error), + ] { + if let Err(error) = result { + failure = Some(match failure.take() { + Some(previous) => { + previous.attach(format!("another private JS cleanup failed: {error:?}")) + } + None => error.attach("private JS resource cleanup failed"), + }); + } + } + } + match failure { + Some(error) => Err(error), + None => Ok(()), + } + } + /// Creates both independent source/state trees from approved tracked bytes. + /// + /// # Errors + /// + /// Returns an error for changed/unjoined inputs, root hooks or failed private state. + pub(crate) fn new( + source: &'a ApprovedSource, + locked: &LockedMaterials, + originals: &'a PreparedNpmOriginals, + tools: SelectedJsTools, + attempt: JsAttempt, + ) -> Result> { + attempt.check()?; + let root_review = locked.review_root(source)?; + let lock = source.read_tracked( + "crates/trusted-server-js/lib/package-lock.json", + Role::Metadata, + )?; + if originals.source_binding() + != ( + source.commit().as_str(), + hex::encode(Sha256::digest(lock)).as_str(), + ) + { + return Err(preparation_error( + "npm originals are bound to different approved source or lockfile", + )); + } + originals.verify_unchanged()?; + let first = source.materialize(attempt.deadline, &attempt.cancellation)?; + let module_ids = first.module_ids(); + if module_ids.is_empty() || !module_ids.iter().any(|id| id == "core") { + return Err(preparation_error( + "approved source lists no full JS module set", + )); + } + let sides = [ + Side::new(first, &module_ids, &attempt)?, + Side::new( + source.materialize(attempt.deadline, &attempt.cancellation)?, + &module_ids, + &attempt, + )?, + ]; + let tools_digest = tools.digest(&attempt)?; + let declarations = originals.declared_hooks(); + Ok(Self { + source, + originals, + tools, + tools_digest, + module_ids, + sides, + attempt, + declarations, + _root_review: root_review, + }) + } + + /// Returns exactly one side's roots; the other side is never mounted. + /// + /// # Errors + /// + /// Returns an error for an unknown side or unsafe/overlapping roots. + pub(crate) fn boundary_roots( + &self, + index: usize, + ) -> Result> { + let side = self + .sides + .get(index) + .ok_or_else(|| preparation_error("unknown JS comparison side"))?; + BoundaryRoots::new( + &[ + self.originals.root().to_owned(), + side.metadata.path().to_owned(), + ], + std::slice::from_ref(&self.tools.root), + &[side.source.root().to_owned(), side.state.path().to_owned()], + ) + } + + pub(super) fn check(&self) -> Result<(), Report> { + self.attempt.check()?; + self.originals.verify_unchanged()?; + if self.tools.digest(&self.attempt)? != self.tools_digest { + return Err(preparation_error("selected JS tools changed")); + } + Ok(()) + } + + pub(super) fn command( + &self, + index: usize, + boundary: &Boundary, + ) -> Result> { + self.command_with_npm_path(index, boundary, true) + } + + pub(super) fn command_with_npm_path( + &self, + index: usize, + boundary: &Boundary, + include_npm: bool, + ) -> Result> { + self.check()?; + let side = &self.sides[index]; + let remaining = self + .attempt + .deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| preparation_error("JS deadline expired"))?; + let bin = boundary + .execution_path(&self.tools.node())? + .parent() + .ok_or_else(|| preparation_error("selected Node has no parent"))? + .to_owned(); + let path = if include_npm { + format!("{}:/usr/bin:/bin", bin.display()) + } else { + "/usr/bin:/bin".to_owned() + }; + ConfinedCommand::new( + &self.tools.node(), + &side.library(), + ProcessLimits { + wall: remaining.min(Duration::from_secs(1200)), + stdout_max: 16 * 1024 * 1024, + stderr_max: 16 * 1024 * 1024, + }, + )? + .environment("PATH", path)? + .environment( + "HOME", + boundary.execution_path(&side.state.path().join("home"))?, + )? + .environment( + "TMPDIR", + boundary.execution_path(&side.state.path().join("tmp"))?, + )? + .environment("LANG", "C.UTF-8")? + .environment("LC_ALL", "C.UTF-8")? + .environment("TZ", "UTC")? + .environment("TRUSTED_SERVER__RELEASE_BUILD", "1")? + .environment("TRUSTED_SERVER__GIT_VERSION", self.source.commit().as_str())? + .environment("TRUSTED_SERVER__RELEASE_JS_TESTS", "1") + .and_then(|command| { + command.environment( + "TRUSTED_SERVER__RELEASE_JS_SOURCE_CONTEXT", + boundary.execution_path(&side.metadata.path().join("source-context.json"))?, + ) + }) + .map(|command| command.cancellation(self.attempt.cancellation.clone())) + } + + pub(super) fn run_command( + &self, + index: usize, + boundary: &Boundary, + proof: &DenialProof, + arguments: Vec, + ) -> Result, Report> { + let command = self.command(index, boundary)?.arguments(arguments); + let output = boundary.run_confined(&command, proof)?.require_success()?; + self.sides[index].verify_source()?; + self.check()?; + Ok(output) + } + + pub(super) fn install( + &self, + index: usize, + boundary: &Boundary, + proof: &DenialProof, + ) -> Result> { + if !boundary.matches_roots(&self.boundary_roots(index)?) + || !boundary.has_owned_descendant_cleanup() + { + return Err(preparation_error( + "JS installation requires exact side roots and owned descendant cleanup", + )); + } + let side = &self.sides[index]; + let npm = boundary.execution_path(&self.tools.npm())?; + let cache = boundary.execution_path(&side.state.path().join("cache"))?; + let versions = + self.run_command(index, boundary, proof, vec![OsString::from("--version")])?; + if versions != b"v24.20.0\n" { + return Err(preparation_error( + "selected Node version differs from approved pin", + )); + } + let version = self.run_command( + index, + boundary, + proof, + vec![npm.as_os_str().to_owned(), OsString::from("--version")], + )?; + if version != b"11.19.0\n" { + return Err(preparation_error( + "selected npm version differs from approved pin", + )); + } + for archives in self.originals.archive_paths().chunks(32) { + let mut arguments = vec![ + npm.as_os_str().to_owned(), + OsString::from("cache"), + OsString::from("add"), + ]; + for archive in archives { + arguments.push(boundary.execution_path(archive)?.into_os_string()); + } + arguments.extend( + [ + "--offline", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--progress=false", + "--userconfig=/dev/null", + ] + .map(OsString::from), + ); + arguments.push(OsString::from(format!( + "--globalconfig={}/release-disabled-global.npmrc", + cache.display() + ))); + arguments.extend([OsString::from("--cache"), cache.as_os_str().to_owned()]); + self.run_command(index, boundary, proof, arguments)?; + } + let key = random_bytes::<32>().change_context(ReleaseError::Material)?; + let nonce = random_bytes::<32>().change_context(ReleaseError::Material)?; + let guard = boundary.execution_path(&self.tools.guard())?; + let request = json!({"schema_version":1,"key":hex::encode(key),"nonce":hex::encode(nonce),"source_commit":self.source.commit().as_str(),"repo_root":boundary.execution_path(side.source.root())?,"cache":cache,"guard_path":guard,"native_esbuild":self.originals.native_esbuild(std::env::consts::ARCH)?}); + let command = self + .command(index, boundary)? + .arguments([ + boundary + .execution_path(&side.source.root().join("scripts/release/observe-npm.mjs"))? + .into_os_string(), + OsString::from("--guard"), + guard.into_os_string(), + ]) + .stdin_bytes(serde_json::to_vec(&request).map_err(preparation_error)?); + let output = boundary.run_confined(&command, proof)?.require_success()?; + let observation = authenticate_observation(&output, &key, &request, &self.declarations)?; + fs::write( + side.metadata.path().join("preparation.json"), + serde_json::to_vec(&observation.preparation).map_err(preparation_error)?, + ) + .map_err(preparation_error)?; + side.verify_source()?; + self.check()?; + Ok(observation) + } + + pub(super) fn collector_arguments( + &self, + index: usize, + boundary: &Boundary, + ) -> Result, Report> { + let side = &self.sides[index]; + Ok(vec![ + boundary + .execution_path(&side.source.root().join("scripts/release/collect-js.mjs"))? + .into_os_string(), + OsString::from("--out-dir"), + boundary + .execution_path(&side.output().join("bundles"))? + .into_os_string(), + OsString::from("--inventory"), + boundary + .execution_path(&side.output().join("tsjs-inventory.json"))? + .into_os_string(), + OsString::from("--preparation"), + boundary + .execution_path(&side.metadata.path().join("preparation.json"))? + .into_os_string(), + OsString::from("--source-context"), + boundary + .execution_path(&side.metadata.path().join("source-context.json"))? + .into_os_string(), + ]) + } +} + +fn authenticate_observation( + output: &[u8], + key: &[u8; 32], + request: &Value, + declarations: &BTreeMap<(String, String), String>, +) -> Result> { + let mut accepted = None; + for line in output.split(|byte| *byte == b'\n') { + let Some(encoded) = line.strip_prefix(PREFIX) else { + continue; + }; + let Some(space) = encoded.iter().position(|byte| *byte == b' ') else { + continue; + }; + let Ok(bytes) = STANDARD.decode(&encoded[..space]) else { + continue; + }; + let Ok(signature) = hex::decode(&encoded[space + 1..]) else { + continue; + }; + let mut mac = Hmac::::new_from_slice(key).map_err(preparation_error)?; + mac.update(&bytes); + if mac.verify_slice(&signature).is_err() { + continue; + } + if accepted.is_some() { + return Err(preparation_error("multiple authenticated npm observations")); + } + accepted = Some((parse_json(&bytes, Role::Metadata)?, bytes)); + } + let (observation, raw) = + accepted.ok_or_else(|| preparation_error("no authenticated npm lifecycle observation"))?; + let object = observation + .as_object() + .ok_or_else(|| preparation_error("npm observation is not an object"))?; + let expected_keys = BTreeSet::from([ + "schema_version", + "nonce", + "source_commit", + "repo_root", + "cache", + "guard_path", + "native_esbuild", + "normalizations", + "success", + "npm_exit_code", + "events", + "preparation", + ]); + if object.keys().map(String::as_str).collect::>() != expected_keys + || observation["success"] != true + || observation["npm_exit_code"] != 0 + { + return Err(preparation_error( + "npm observation did not prove complete successful execution", + )); + } + for field in [ + "schema_version", + "nonce", + "source_commit", + "repo_root", + "cache", + "guard_path", + "native_esbuild", + ] { + if observation.get(field) != request.get(field) { + return Err(preparation_error( + "npm observation belongs to another private request", + )); + } + } + let events = observation["events"] + .as_array() + .ok_or_else(|| preparation_error("npm observation has no event array"))?; + let mut states = BTreeMap::<(String, String), u8>::new(); + for event in events { + let text = |name: &str| { + event + .get(name) + .and_then(Value::as_str) + .ok_or_else(|| preparation_error("lifecycle event lacks a required string")) + }; + let identity = (text("lock_path")?.to_owned(), text("event")?.to_owned()); + if declarations.get(&identity).map(String::as_str) != Some(text("script_sha256")?) { + return Err(preparation_error( + "observed hook differs from authenticated declaration", + )); + } + let state = states.entry(identity).or_default(); + match (text("stage")?, *state) { + ("start", 0) => *state = 1, + ("success", 1) if event["exit_code"] == 0 && event["signal"].is_null() => *state = 2, + _ => { + return Err(preparation_error( + "hook failed, repeated or lacks successful completion", + )); + } + } + } + if states.len() != declarations.len() || states.values().any(|state| *state != 2) { + return Err(preparation_error( + "not every reviewed hook executed successfully", + )); + } + if !observation["preparation"].is_object() || !observation["normalizations"].is_array() { + return Err(preparation_error( + "npm observation lacks measured preparation", + )); + } + let normalizations = observation["normalizations"] + .as_array() + .ok_or_else(|| preparation_error("normalizations must be an array"))?; + if normalizations.len() > 1 { + return Err(preparation_error( + "native tool was normalized more than once", + )); + } + for normalization in normalizations { + let native = &request["native_esbuild"]; + let lock_path = native["lock_path"] + .as_str() + .ok_or_else(|| preparation_error("native expectation lacks locked package identity"))?; + let expected = json!({ + "kind":"esbuild-hardlink-copy-v1", + "path":"node_modules/esbuild/bin/esbuild", + "native_path":format!("{lock_path}/bin/esbuild"), + "sha256":native["sha256"],"size":native["size"],"mode":native["mode"], + }); + if *normalization != expected { + return Err(preparation_error( + "native normalization differs from independently authenticated original", + )); + } + } + Ok(ObservedInstall { + preparation: observation["preparation"].clone(), + raw, + }) +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/trusted-server-release/src/preparation/tests.rs b/crates/trusted-server-release/src/preparation/tests.rs new file mode 100644 index 000000000..cd63f3de1 --- /dev/null +++ b/crates/trusted-server-release/src/preparation/tests.rs @@ -0,0 +1,118 @@ +use super::*; + +fn request() -> Value { + json!({"schema_version":1,"nonce":"a".repeat(64),"source_commit":"b".repeat(40),"repo_root":"/example/source","cache":"/example/cache","guard_path":"/example/tools/guard.node","native_esbuild":{"lock_path":"node_modules/@esbuild/linux-x64","sha256":"c".repeat(64),"size":1,"mode":493}}) +} +fn summary() -> Value { + let mut value = request(); + value["success"] = json!(true); + value["npm_exit_code"] = json!(0); + value["events"] = json!([]); + value["normalizations"] = json!([]); + value["preparation"] = json!({"schema_version":1}); + value +} +fn seal(value: &Value, key: &[u8; 32]) -> Vec { + let bytes = serde_json::to_vec(value).expect("should encode fixture observation"); + let mut mac = + Hmac::::new_from_slice(key).expect("should initialize fixture authenticator"); + mac.update(&bytes); + format!( + "{}{} {}\n", + std::str::from_utf8(PREFIX).expect("should decode fixed prefix"), + STANDARD.encode(bytes), + hex::encode(mac.finalize().into_bytes()) + ) + .into_bytes() +} +#[test] +fn unauthenticated_child_output_cannot_construct_an_install_observation() { + let key = random_bytes::<32>().expect("should read an authentication key"); + let mut forged_key = key; + forged_key[0] ^= 1; + assert!( + authenticate_observation( + &seal(&summary(), &forged_key), + &key, + &request(), + &BTreeMap::new() + ) + .is_err(), + "should reject a child-forged observation" + ); +} +#[test] +fn observation_requires_one_successful_private_request() { + let key = random_bytes::<32>().expect("should read an authentication key"); + authenticate_observation(&seal(&summary(), &key), &key, &request(), &BTreeMap::new()) + .expect("should accept an authenticated protocol fixture"); + for field in [ + "nonce", + "source_commit", + "repo_root", + "cache", + "guard_path", + "native_esbuild", + "schema_version", + "success", + "npm_exit_code", + ] { + let mut changed = summary(); + changed[field] = Value::Null; + assert!( + authenticate_observation(&seal(&changed, &key), &key, &request(), &BTreeMap::new()) + .is_err(), + "should reject an altered binding or failed observation" + ); + } + let mut duplicate = seal(&summary(), &key); + duplicate.extend(seal(&summary(), &key)); + assert!( + authenticate_observation(&duplicate, &key, &request(), &BTreeMap::new()).is_err(), + "should reject repeated authenticated completion" + ); +} +#[test] +fn hook_start_does_not_substitute_for_successful_terminal_execution() { + let key = random_bytes::<32>().expect("should read an authentication key"); + let digest = "d".repeat(64); + let declarations = BTreeMap::from([( + ("node_modules/example".to_owned(), "postinstall".to_owned()), + digest.clone(), + )]); + let start = json!({"lock_path":"node_modules/example","event":"postinstall","script_sha256":digest,"stage":"start","changes":[]}); + let mut success = start.clone(); + success["stage"] = json!("success"); + success["exit_code"] = json!(0); + success["signal"] = Value::Null; + let mut value = summary(); + value["events"] = json!([start.clone(), success.clone()]); + authenticate_observation(&seal(&value, &key), &key, &request(), &declarations) + .expect("should join start and successful completion"); + for events in [ + json!([]), + json!([start.clone()]), + json!([success]), + json!([start.clone(), start]), + ] { + value["events"] = events; + assert!( + authenticate_observation(&seal(&value, &key), &key, &request(), &declarations).is_err(), + "should reject missing, repeated or out-of-order hook execution" + ); + } +} + +#[cfg(target_os = "linux")] +pub(crate) mod full; + +#[test] +fn normalization_receipt_cannot_assert_another_native_tool_identity() { + let key = random_bytes::<32>().expect("should read an authentication key"); + let mut value = summary(); + value["normalizations"] = json!([{"kind":"esbuild-hardlink-copy-v1","path":"node_modules/esbuild/bin/esbuild","native_path":"node_modules/@esbuild/linux-x64/bin/esbuild","sha256":"f".repeat(64),"size":1,"mode":493}]); + assert!( + authenticate_observation(&seal(&value, &key), &key, &request(), &BTreeMap::new()).is_err(), + "should bind normalization to independently authenticated native bytes" + ); +} diff --git a/crates/trusted-server-release/src/preparation/tests/full.rs b/crates/trusted-server-release/src/preparation/tests/full.rs new file mode 100644 index 000000000..1a7786a94 --- /dev/null +++ b/crates/trusted-server-release/src/preparation/tests/full.rs @@ -0,0 +1,283 @@ +//! Explicit real Linux fixture; never selected by a normal focused unit run. + +use super::*; +use crate::boundary::{DockerSettings, ImageSelection, ProbeProgram}; +use crate::documents::read_bounded; +use crate::javascript::JsBuildCompleted; +use crate::preflight::{AuditedEnvironment, audit_environment}; +use crate::source::{CommitId, authenticate}; + +fn host_limits() -> ProcessLimits { + ProcessLimits { + wall: Duration::from_secs(300), + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + } +} + +/// One real two-build JS fixture run and the authenticated source it built. +/// +/// Ordinary CI evidence only: the tools, image and fixture acquisition are +/// unqualified, so nothing here is release qualification. +pub(crate) struct JsFixtureRun { + /// The explicitly supplied fixture root. + pub(crate) fixture: PathBuf, + /// The audited minimal environment the source was authenticated under. + pub(crate) audited: AuditedEnvironment, + /// The locally authenticated fixture source. + pub(crate) source: ApprovedSource, + /// The locked materials derived from that source. + pub(crate) locked: LockedMaterials, + /// The completed JS handoff of build A. + pub(crate) completed: JsBuildCompleted, + /// The ordinary CI measurement of both JS builds. + pub(crate) measurement: Value, +} + +/// Runs two fresh offline JS builds from the fixture named by +/// `TS_RELEASE_JS_FIXTURE_ROOT` and returns the retained A handoff. +/// +/// # Panics +/// +/// Panics when the fixture is missing or any preparation, boundary or build +/// step fails; this is an explicit CI fixture, not a production path. +pub(crate) fn run_two_fresh_js_builds() -> JsFixtureRun { + let fixture = std::env::var_os("TS_RELEASE_JS_FIXTURE_ROOT") + .map(PathBuf::from) + .expect("should explicitly supply original-only fixture root"); + let fixture = fs::canonicalize(fixture).expect("should resolve fixture root"); + let context = read_bounded(&fixture.join("fixture.json"), Role::Metadata) + .expect("should read fixture context"); + let context = parse_json(&context.bytes, Role::Metadata).expect("should parse fixture context"); + let commit = CommitId::parse( + context["source_commit"] + .as_str() + .expect("should retain source identity"), + ) + .expect("should parse independently selected source identity"); + let audited = audit_environment(&[(OsString::from("PATH"), OsString::from("/usr/bin:/bin"))]) + .expect("should audit minimal source environment"); + let source = authenticate(&fixture.join("source"), &commit, &audited) + .expect("should authenticate fixture tracked source"); + let locked = + LockedMaterials::from_source(&source).expect("should derive approved locked npm materials"); + let (completed, measurement) = two_fresh_js_builds(&fixture, &context, &source, &locked); + JsFixtureRun { + fixture, + audited, + source, + locked, + completed, + measurement, + } +} + +/// Prepares, proves and runs both JS builds, returning the handoff and measurement. +fn two_fresh_js_builds( + fixture: &Path, + context: &Value, + source: &ApprovedSource, + locked: &LockedMaterials, +) -> (JsBuildCompleted, Value) { + let cancellation = Cancellation::default(); + let deadline = Instant::now() + Duration::from_secs(3600); + let originals = locked + .prepare_npm_originals(&fixture.join("originals"), deadline, &cancellation) + .expect("should authenticate every original archive and review declarations"); + let tools = SelectedJsTools::for_tests(&fixture.join("tools")) + .expect("should measure ordinary CI candidate tools"); + let prepared = PreparedJsBuild::new( + source, + locked, + &originals, + tools, + JsAttempt::new(deadline, cancellation), + ) + .expect("should own two independent fresh installations"); + let host = tempfile::tempdir().expect("should create disjoint boundary host state"); + let image = ImageSelection::Test { + id: context["image_id"] + .as_str() + .expect("should select the already-built ordinary CI fixture image") + .to_owned(), + }; + // These IDs only select the existing unprivileged CI worker account. + let user = unsafe { (libc::getuid(), libc::getgid()) }; + let settings = DockerSettings { + client: PathBuf::from("/usr/bin/docker"), + cpus: 1, + memory_bytes: 4 * 1024 * 1024 * 1024, + pids_limit: 512, + tmpfs_bytes: 256 * 1024 * 1024, + user, + stop_timeout_seconds: 5, + host_limits: host_limits(), + }; + let first_roots = prepared + .boundary_roots(0) + .expect("should select only A roots"); + let second_roots = prepared + .boundary_roots(1) + .expect("should select only B roots"); + let first = Boundary::linux_container( + first_roots.clone(), + settings.clone(), + image.clone(), + host.path(), + ) + .expect("should configure A owned PID/network boundary"); + let second = Boundary::linux_container(second_roots.clone(), settings, image, host.path()) + .expect("should configure B disjoint boundary"); + for (own, other) in [(&first_roots, &second_roots), (&second_roots, &first_roots)] { + let mut shared = 0; + for (_, _, root) in other.entries() { + if own.entries().any(|(_, _, own_root)| own_root == root) { + shared += 1; + assert!( + !own.writable().iter().any(|path| path == root), + "should share only read-only roots between A and B" + ); + } else { + assert!( + own.entries() + .all(|(_, _, own_root)| !own_root.starts_with(root) + && !root.starts_with(own_root)), + "should never expose the other side's private source, installation or metadata root" + ); + } + } + assert_eq!( + shared, 2, + "should share exactly the read-only originals and selected tool roots" + ); + } + assert!( + first.matches_roots(&first_roots) && second.matches_roots(&second_roots), + "should bind each boundary to exactly its own selected roots" + ); + assert!( + !first.matches_roots(&second_roots) && !second.matches_roots(&first_roots), + "should refuse either side's commands inside the other side's boundary" + ); + let probe = ProbeProgram::from_staged_release_binary(&fixture.join("tools/probe")) + .expect("should select staged native producer probe"); + let limits = ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 4096, + stderr_max: 4096, + }; + let first_proof = first + .establish_denial(&probe, limits) + .expect("should establish real A positive and denied socket controls"); + let second_proof = second + .establish_denial(&probe, limits) + .expect("should establish independent real B controls"); + let owned_roots: Vec<_> = prepared + .sides + .iter() + .flat_map(|side| { + [ + side.source.root().to_owned(), + side.state.path().to_owned(), + side.metadata.path().to_owned(), + ] + }) + .collect(); + let module_count = prepared.module_ids.len(); + let completed=prepared.run([&first,&second],[&first_proof,&second_proof]).expect("should complete both actual fresh offline installations, lifecycle observations, negative tool controls, full suites and final-byte equality"); + assert!( + owned_roots.iter().all(|root| !root.exists()), + "should close all six private source, state and metadata roots of both builds before completion" + ); + assert_eq!( + completed.source_commit(), + source.commit().as_str(), + "should bind actual approved source" + ); + assert!( + completed.test_counts().iter().all(|count| *count > 0), + "should execute nonzero full registered suites on both sides" + ); + assert_eq!( + completed.tools_sha256().len(), + 64, + "should bind measured complete tool tree" + ); + assert_eq!( + completed.settings_sha256().len(), + 64, + "should bind fixed execution settings" + ); + assert_eq!( + completed.inventory_sha256().len(), + 64, + "should retain original A inventory digest" + ); + completed + .rehash_for_handoff() + .expect("should rejoin retained A bytes before downstream handoff"); + let contributors = completed + .retained_contributions() + .expect("should export actual collector origins after private-root cleanup"); + assert_eq!( + contributors.inventory_sha256(), + completed.inventory_sha256(), + "should preserve the original collector inventory identity" + ); + assert_eq!( + contributors.modules().len(), + module_count, + "should export every actual final module with its conservative origins" + ); + let measurement = json!({ + "schema_version": 1, + "kind": "ordinary-ci-two-fresh-js-builds", + "source_commit": completed.source_commit(), + "test_counts": completed.test_counts(), + "module_count": module_count, + "tools_sha256": completed.tools_sha256(), + "settings_sha256": completed.settings_sha256(), + "inventory_sha256": completed.inventory_sha256(), + "private_roots_removed": owned_roots.len(), + "release_qualification": false, + }); + (completed, measurement) +} + +/// Runs the two fresh JS builds and exports the retained handoff and its +/// measurement into the fresh directory named by +/// `TS_RELEASE_JS_HANDOFF_EXPORT`, for the macOS candidate jobs to adopt. +/// +/// The export is an unauthenticated ordinary CI artifact; adoption labels it +/// so, and nothing here is release qualification. +#[test] +#[ignore = "requires explicit original-only fixture, an export destination and Linux Docker; run separately in producer CI"] +fn export_js_handoff_for_candidates() { + let destination = std::env::var_os("TS_RELEASE_JS_HANDOFF_EXPORT") + .map(PathBuf::from) + .expect("should explicitly supply the JS handoff export destination"); + let run = run_two_fresh_js_builds(); + run.completed + .export_ordinary_ci_handoff_for_tests(&run.measurement, &destination) + .expect("should export the rehashed handoff and its measurement"); + let (handoff, measurement) = + JsBuildCompleted::read_exported_ordinary_ci_handoff_for_tests(&destination) + .expect("should read back exactly the exported handoff and measurement"); + JsBuildCompleted::adopt_ordinary_ci_handoff_for_tests(&run.source, &handoff, &measurement) + .expect("should adopt the export as the macOS jobs will"); + println!( + "{}", + serde_json::to_string_pretty(&run.measurement).expect("should encode the JS measurement") + ); +} + +#[test] +#[ignore = "requires explicit original-only fixture and Linux Docker; run separately in producer CI"] +fn real_two_fresh_js_builds() { + let run = run_two_fresh_js_builds(); + fs::write( + run.fixture.join("js-execution-measurement.json"), + serde_json::to_vec_pretty(&run.measurement).expect("should encode actual CI measurement"), + ) + .expect("should retain actual CI measurement after successful handoff"); +} diff --git a/crates/trusted-server-release/src/recipe.rs b/crates/trusted-server-release/src/recipe.rs new file mode 100644 index 000000000..36333db60 --- /dev/null +++ b/crates/trusted-server-release/src/recipe.rs @@ -0,0 +1,812 @@ +//! Closed recipe layer for one reproducible Cargo build. +//! +//! The recipe validates and binds every input a Cargo execution needs, derives +//! the exact plan in the build's own path namespace, runs it confined, and +//! parses and copies the result of a successful run. Only the test-only +//! ordinary CI tool selection reaches the run, so every completed build is +//! unqualified candidate evidence; production execution stays closed until a +//! toolchain, boundary and image are qualified. +//! +//! # Roots +//! +//! The checkout, the retained JS handoff and the prepared vendor materials are +//! mounted read-only. The tools are read-only. Two roots are writable: the +//! fresh `cargo_state` root (Cargo home, `HOME` and `TMPDIR`) and the Cargo +//! target directory. The output directory is not mounted at all: the host +//! copies the executable into it after the run. + +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::boundary::BoundaryRoots; +use crate::composition::origin::WorkspaceOrigins; +use crate::documents::Role as DocumentRole; +use crate::error::ReleaseError; +use crate::javascript::JsBuildCompleted; +use crate::javascript::RetainedJsModule; +use crate::materials::PreparedCargoMaterials; +#[cfg(test)] +use crate::policy::ReleaseTarget; +use crate::policy::{FIXED_ARTIFACTS, FixedArtifact}; +use crate::preflight::AuditedEnvironment; +use crate::preflight::child::{ChildEnvironment, ChildRequest, child_environment}; +use crate::source::{ApprovedSource, CommitId, FreshSource}; +use fresh::{ + absent_in_real_parent, disjoint, empty_directory, normal_absolute, prepared_materials, + real_directory, real_file, +}; + +mod ambient; +mod apple; +mod artifact; +mod cargo_home; +mod cargo_output; +mod compare; +mod completed; +mod execute; +pub(crate) mod fastly_tools; +mod final_paths; +mod fresh; +pub(crate) mod gnu; +mod identity; +mod native; +mod paths; +mod plan; +mod remap; +mod runtime; +mod sources; +mod startup; +mod tools; + +pub(crate) use ambient::{AmbientProfile, CheckedAmbient}; +#[allow( + unused_imports, + reason = "the qualified Apple selection has no production caller yet" +)] +pub(crate) use apple::{ + AppleDeveloperTools, AppleToolEvidence, AppleToolFacts, QualifiedAppleTools, +}; +pub(crate) use cargo_output::successful_messages as observed_cargo_messages; +pub(crate) use completed::RecipeCompleted; +pub(crate) use identity::{ExactInvocationDigest, RecipeDefinitionIdentity}; +pub(crate) use paths::ExecutionPaths; +pub(crate) use plan::CargoPlan; +#[allow( + unused_imports, + reason = "the qualified selection has no production caller yet" +)] +pub(crate) use tools::{ + Authority, QualifiedRustTools, RustToolEvidence, SelectedRustTools, ToolSelection, +}; + +#[cfg(test)] +use execute::RunOutput; +use execute::retained_tree_digest; +use plan::{PlanInputs, check_child_state, plan_for}; + +/// The only released feature variant of every asset. +const DEFAULT_VARIANT: &str = "default"; + +/// Builds a recipe error carrying `message`. +pub(crate) fn recipe_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Recipe).attach(message.to_string()) +} + +/// One source-owned asset identity, reachable only through the fixed table. +#[derive(Clone, Copy)] +pub(crate) struct AssetId { + artifact: &'static FixedArtifact, +} + +impl std::fmt::Debug for AssetId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_tuple("AssetId") + .field(&self.artifact.name) + .finish() + } +} + +impl PartialEq for AssetId { + fn eq(&self, other: &Self) -> bool { + self.artifact.name == other.artifact.name + } +} + +impl Eq for AssetId {} + +impl AssetId { + /// Resolves an asset name against the compiled fixed table. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for any name not in the table. + pub(crate) fn parse(name: &str) -> Result> { + FIXED_ARTIFACTS + .iter() + .find(|artifact| artifact.name == name) + .map(|artifact| Self { artifact }) + .ok_or_else(|| recipe_error(format!("unknown asset id {name:?}"))) + } + + /// Returns the fixed table row; JSON can never supply its fields. + pub(crate) fn artifact(&self) -> &'static FixedArtifact { + self.artifact + } + + /// Resolves a Cargo denial fixture member by package name, for tests only. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for any package not in the test rows. + #[cfg(test)] + pub(crate) fn denial_fixture(package: &str) -> Result> { + crate::policy::DENIAL_FIXTURE_ARTIFACTS + .iter() + .find(|artifact| artifact.package == package) + .map(|artifact| Self { artifact }) + .ok_or_else(|| recipe_error(format!("unknown denial fixture member {package:?}"))) + } + + /// Resolves a Cargo denial fixture member built for a Darwin `target`, + /// for tests only. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for any package or target not in the + /// Darwin test rows. + #[cfg(test)] + pub(crate) fn darwin_denial_fixture( + package: &str, + target: ReleaseTarget, + ) -> Result> { + crate::policy::DARWIN_DENIAL_FIXTURE_ARTIFACTS + .iter() + .find(|artifact| artifact.package == package && artifact.target == target) + .map(|artifact| Self { artifact }) + .ok_or_else(|| { + recipe_error(format!( + "unknown Darwin denial fixture member {package:?} for {}", + target.triple() + )) + }) + } +} + +/// Which independent build of the pair a recipe belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Role { + /// The canonical build whose output is proposed for publication. + Canonical, + /// The independent reproduction under varied ambient conditions, which + /// must match the canonical build. + Reproduction, +} + +impl Role { + /// Parses exactly `A` or `B`, the external spelling of the two roles. + /// + /// The spec (R-REPEAT, "Build A" and "Build B") and the closed + /// `build.sh --role A|B` interface of the implementation plan (Task 9) + /// name the roles `A` and `B`, as do the tracked `ambient_profiles` keys + /// of `release-tools.json`. This and [`Self::spec_label`] are the only + /// places that spelling maps to [`Self::Canonical`] and + /// [`Self::Reproduction`]. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for any other text. + pub(crate) fn parse(text: &str) -> Result> { + match text { + "A" => Ok(Self::Canonical), + "B" => Ok(Self::Reproduction), + _ => Err(recipe_error(format!( + "unknown role {text:?}; expected A or B" + ))), + } + } + + /// Returns the external spelling [`Self::parse`] accepts for this role. + pub(crate) fn spec_label(self) -> &'static str { + match self { + Self::Canonical => "A", + Self::Reproduction => "B", + } + } +} + +/// Requires the default feature variant. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for any other variant name. +pub(crate) fn check_variant(variant: &str) -> Result<(), Report> { + if variant != DEFAULT_VARIANT { + return Err(recipe_error(format!( + "variant {variant:?} is rejected; only the default feature set is released" + ))); + } + Ok(()) +} + +/// Unvalidated textual request fields, exactly as an operator supplied them. +pub(crate) struct RecipeInput<'a> { + /// Asset name, resolved only against the compiled fixed table. + pub(crate) asset: &'a str, + /// Feature variant; only `default` is accepted. + pub(crate) variant: &'a str, + /// `A` (canonical) or `B` (reproduction); see [`Role::parse`]. + pub(crate) role: &'a str, + /// Approved 40-digit source commit. + pub(crate) commit: &'a str, + /// Retained directory of the final JS bundles. + pub(crate) js_dir: &'a Path, + /// Retained JS inventory file beside the bundle directory. + pub(crate) js_inventory: &'a Path, + /// Prepared materials root holding exactly the vendor directory. + pub(crate) materials: &'a Path, + /// Fresh empty directory for Cargo home, `HOME` and `TMPDIR`. + pub(crate) cargo_state: &'a Path, + /// Fresh empty directory receiving the built artifact; never mounted. + pub(crate) output: &'a Path, + /// Cargo target directory; must not exist yet. + pub(crate) target_dir: &'a Path, +} + +/// Checks the lexical form, freshness and disjointness of every request path. +fn check_roots(input: &RecipeInput<'_>) -> Result<(), Report> { + let paths = [ + ("JS bundle directory", input.js_dir), + ("JS inventory", input.js_inventory), + ("materials root", input.materials), + ("cargo state root", input.cargo_state), + ("output root", input.output), + ("target directory", input.target_dir), + ]; + for (label, path) in paths { + normal_absolute(label, path)?; + } + real_directory(paths[0].0, input.js_dir)?; + real_file(paths[1].0, input.js_inventory)?; + let retained = input + .js_dir + .parent() + .ok_or_else(|| recipe_error("JS bundle directory has no retained parent"))?; + if input.js_inventory.parent() != Some(retained) { + return Err(recipe_error( + "JS inventory must sit beside the JS bundle directory in the retained root", + )); + } + prepared_materials(paths[2].0, input.materials)?; + empty_directory(paths[3].0, input.cargo_state)?; + empty_directory(paths[4].0, input.output)?; + absent_in_real_parent(paths[5].0, input.target_dir)?; + disjoint(&[ + ("retained JS root", retained), + paths[2], + paths[3], + paths[4], + paths[5], + ])?; + Ok(()) +} + +/// A validated recipe request with fresh, disjoint roots. +#[derive(Debug)] +pub(crate) struct RecipeRequest { + asset: AssetId, + role: Role, + commit: CommitId, + js_dir: PathBuf, + js_inventory: PathBuf, + materials: PathBuf, + cargo_state: PathBuf, + output: PathBuf, + target_dir: PathBuf, +} + +impl RecipeRequest { + /// Parses and validates one request against the compiled cohort and disk. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for an unknown asset or role, a + /// non-default variant, a malformed commit, non-normal or aliased paths, a + /// materials root that is not exactly a prepared vendor directory, a + /// reused state, output or target root, or overlapping roots. + pub(crate) fn parse(input: &RecipeInput<'_>) -> Result> { + check_variant(input.variant)?; + let asset = AssetId::parse(input.asset)?; + Self::with_asset(asset, input) + } + + /// Parses a request for an already resolved test-only asset; the asset + /// text of `input` is ignored. + /// + /// # Errors + /// + /// As [`Self::parse`], except for the asset name. + #[cfg(test)] + pub(crate) fn parse_for_asset( + asset: AssetId, + input: &RecipeInput<'_>, + ) -> Result> { + check_variant(input.variant)?; + Self::with_asset(asset, input) + } + + /// Validates every field but the asset, which the caller resolved. + fn with_asset(asset: AssetId, input: &RecipeInput<'_>) -> Result> { + let role = Role::parse(input.role)?; + let commit = CommitId::parse(input.commit)?; + check_roots(input)?; + Ok(Self { + asset, + role, + commit, + js_dir: input.js_dir.to_owned(), + js_inventory: input.js_inventory.to_owned(), + materials: input.materials.to_owned(), + cargo_state: input.cargo_state.to_owned(), + output: input.output.to_owned(), + target_dir: input.target_dir.to_owned(), + }) + } + + pub(super) fn asset(&self) -> AssetId { + self.asset + } + + pub(super) fn js_dir(&self) -> &Path { + &self.js_dir + } + + pub(super) fn js_inventory(&self) -> &Path { + &self.js_inventory + } + + pub(super) fn target_dir(&self) -> &Path { + &self.target_dir + } + + /// Private `HOME` below the writable cargo state root. + pub(super) fn home(&self) -> PathBuf { + self.cargo_state.join("home") + } + + /// Private Cargo home below the writable cargo state root. + pub(super) fn cargo_home(&self) -> PathBuf { + self.cargo_state.join("cargo-home") + } + + /// Private `TMPDIR` below the writable cargo state root. + pub(super) fn tmpdir(&self) -> PathBuf { + self.cargo_state.join("tmp") + } +} + +/// Derives the private locations a child build receives from the request. +fn child_request(request: &RecipeRequest) -> ChildRequest { + ChildRequest { + target: request.asset.artifact().target, + home: request.home(), + cargo_home: request.cargo_home(), + tool_directories: Vec::new(), + } +} + +/// JS handoff digests an attestation binds. +#[derive(Debug)] +struct JsFacts { + inventory_sha256: String, + settings_sha256: String, + tools_sha256: String, + modules: Vec, + contributions: Option, +} + +/// Source authority of one recipe; bypass exists only in explicit test fixtures. +enum WorkspaceSelection { + Approved(Box), + #[cfg(test)] + Fixture, +} + +struct ApprovedWorkspace { + origins: WorkspaceOrigins, + definition: RecipeDefinitionIdentity, +} + +/// Everything [`PreparedRecipe::bind`] cross-checks before a recipe may run. +struct BindInputs<'a> { + commit: &'a CommitId, + workspace: WorkspaceSelection, + child: ChildEnvironment, + completed: &'a JsBuildCompleted, + tools: SelectedRustTools, + materials: PreparedCargoMaterials, + ambient: CheckedAmbient, + request: RecipeRequest, +} + +/// A recipe whose source, JS handoff, tools, materials, ambient and roots are bound. +pub(crate) struct PreparedRecipe { + request: RecipeRequest, + tools: SelectedRustTools, + tools_digest: String, + materials: PreparedCargoMaterials, + ambient: CheckedAmbient, + child: ChildEnvironment, + retained_root: PathBuf, + retained_digest: String, + js: JsFacts, + workspace: WorkspaceSelection, +} + +impl PreparedRecipe { + /// Binds the locally authenticated source and a fresh private checkout. + /// + /// The commit and committer epoch reach the child environment only + /// through [`child_environment`], which requires the authenticated source. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the fresh checkout is not the + /// approved commit or not the ambient checkout root, the prepared + /// materials were derived from another `Cargo.lock`, the tracked Cargo + /// configuration sets `build.rustflags`, which the path remap would + /// supersede, a legacy extensionless `.cargo/config` is tracked or on + /// disk at the checkout root, or as [`Self::bind`]. + #[allow( + clippy::too_many_arguments, + reason = "each argument is a distinct authority" + )] + pub(crate) fn new( + source: &ApprovedSource, + fresh: &FreshSource, + audited: &AuditedEnvironment, + completed: &JsBuildCompleted, + tools: SelectedRustTools, + materials: PreparedCargoMaterials, + ambient: CheckedAmbient, + request: RecipeRequest, + ) -> Result> { + if fresh.commit() != source.commit().as_str() { + return Err(recipe_error("fresh checkout is not the approved commit")); + } + if fresh.root() != ambient.checkout_root() { + return Err(recipe_error( + "fresh checkout is not the ambient checkout root", + )); + } + let lock = source + .read_tracked("Cargo.lock", DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + if hex::encode(Sha256::digest(&lock)) != materials.cargo_lock_sha256() { + return Err(recipe_error( + "prepared materials were derived from another Cargo.lock", + )); + } + let config = source + .read_tracked(remap::CARGO_CONFIG, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + remap::check_cargo_config(&config)?; + remap::check_no_extensionless_config( + fresh.root(), + source.tracked_sha256(remap::LEGACY_CARGO_CONFIG).is_some(), + )?; + let child = child_environment(audited, source, &child_request(&request))?; + let workspace = ApprovedWorkspace { + origins: WorkspaceOrigins::bind(source, fresh)?, + definition: RecipeDefinitionIdentity::from_source(source, request.asset)?, + }; + Self::bind(BindInputs { + commit: source.commit(), + workspace: WorkspaceSelection::Approved(Box::new(workspace)), + child, + completed, + tools, + materials, + ambient, + request, + }) + } + + /// Cross-checks every input and creates the fresh target and state directories. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the request, JS handoff, child + /// environment or materials name another commit, the JS handoff no longer + /// rehashes or is not the requested directory, the materials are not the + /// requested root or changed, the tools were selected for another target, + /// the ambient role differs, a root is no longer fresh, the checkout, + /// tools, retained JS, materials, state, target and Apple developer roots + /// overlap, Apple developer tools are missing for a Darwin asset, given + /// for another, carry another authority or no longer rehash, or a Darwin + /// tool root and target directory do not share one parent. + fn bind(inputs: BindInputs<'_>) -> Result> { + check_commits(&inputs)?; + check_parts(&inputs)?; + let BindInputs { + workspace, + child, + completed, + tools, + materials, + ambient, + request, + .. + } = inputs; + let retained_root = resolve_js_handoff(completed, &request)?; + check_fresh_and_disjoint(&request, &ambient, &tools, &materials, &retained_root)?; + check_recipe_owned_parent(&request, &tools)?; + let tools_digest = tools.digest()?; + let retained_digest = retained_tree_digest(&retained_root)?; + let contributions = match &workspace { + WorkspaceSelection::Approved(_) => Some(completed.retained_contributions()?), + #[cfg(test)] + WorkspaceSelection::Fixture => None, + }; + create_private_roots(&request)?; + Ok(Self { + js: JsFacts { + inventory_sha256: completed.inventory_sha256().to_owned(), + settings_sha256: completed.settings_sha256().to_owned(), + tools_sha256: completed.tools_sha256().to_owned(), + modules: completed.retained_modules()?, + contributions, + }, + request, + tools, + tools_digest, + materials, + ambient, + child, + retained_root, + retained_digest, + workspace, + }) + } + + /// Derives the exact Cargo invocation in the namespace `paths` describes. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when any path variable, the tool + /// directory or the vendor directory is outside the roots `paths` knows. + pub(crate) fn plan( + &self, + paths: &dyn ExecutionPaths, + ) -> Result> { + plan_for(&PlanInputs { + request: &self.request, + tools: &self.tools, + ambient: &self.ambient, + child: &self.child, + materials: &self.materials, + paths, + }) + } + + /// Returns the boundary roles this recipe requires. + /// + /// The checkout, retained JS and prepared materials are read-only. Cargo + /// home, `HOME`, `TMPDIR` and the target directory are the only writable + /// roots, and the output directory is not mounted. A Darwin build adds + /// its detached SDK as a scanned immutable root and the Apple developer + /// directory as its only read-and-execute system root. Explicit lexical + /// test fixtures have no detached SDK root. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for missing or overlapping roots. + pub(crate) fn boundary_roots(&self) -> Result> { + let mut immutable = vec![ + self.ambient.checkout_root().to_owned(), + self.retained_root.clone(), + self.request.materials.clone(), + ]; + if let Some(sdk) = self + .tools + .apple() + .and_then(AppleDeveloperTools::owned_sdk_root) + { + immutable.push(sdk.to_owned()); + } + let roots = BoundaryRoots::new( + &immutable, + std::slice::from_ref(&self.tools.root().to_owned()), + &[ + self.request.cargo_state.clone(), + self.request.target_dir.clone(), + ], + )?; + match self.tools.apple() { + Some(apple) => roots.with_system(&[apple.developer_dir().to_owned()]), + None => Ok(roots), + } + } +} + +/// Child variable carrying the authenticated source commit. +const SOURCE_COMMIT_VARIABLE: &str = "TRUSTED_SERVER__GIT_VERSION"; + +/// Requires the request, JS handoff, child environment and materials to name +/// the approved commit. +fn check_commits(inputs: &BindInputs<'_>) -> Result<(), Report> { + let commit = inputs.commit; + if inputs.request.commit != *commit { + return Err(recipe_error("request names another source commit")); + } + if inputs.completed.source_commit() != commit.as_str() { + return Err(recipe_error( + "JS handoff was built from another source commit", + )); + } + let child_commit = inputs + .child + .variables() + .get(SOURCE_COMMIT_VARIABLE) + .map(String::as_str); + if child_commit != Some(commit.as_str()) { + return Err(recipe_error( + "child environment names another source commit", + )); + } + if inputs.materials.source_commit() != commit.as_str() { + return Err(recipe_error( + "prepared materials were derived from another source commit", + )); + } + Ok(()) +} + +/// Requires the materials, tools, ambient role and child state to match the request. +fn check_parts(inputs: &BindInputs<'_>) -> Result<(), Report> { + let request = &inputs.request; + if inputs.materials.root().as_os_str() != request.materials.as_os_str() { + return Err(recipe_error("request does not name the prepared materials")); + } + inputs.materials.rehash().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("prepared materials no longer rehash") + })?; + if inputs.tools.target() != request.asset.artifact().target { + return Err(recipe_error( + "Rust tools were selected for another target than the asset", + )); + } + if inputs.ambient.role() != request.role { + return Err(recipe_error("ambient role differs from the request role")); + } + check_apple_tools(request, &inputs.tools)?; + check_child_state(request, &inputs.child) +} + +/// Requires Apple developer tools for exactly the Darwin targets, carrying +/// the Rust tools' authority and still matching their record. +fn check_apple_tools( + request: &RecipeRequest, + tools: &SelectedRustTools, +) -> Result<(), Report> { + let darwin = request.asset.artifact().target.is_darwin(); + let apple = match (darwin, tools.apple()) { + (false, None) => return Ok(()), + (false, Some(_)) => { + return Err(recipe_error( + "only Darwin assets take Apple developer tools", + )); + } + (true, None) => { + return Err(recipe_error("Darwin assets require Apple developer tools")); + } + (true, Some(apple)) => apple, + }; + if apple.authority() != tools.authority() { + return Err(recipe_error( + "Apple developer tools and Rust tools carry different authority", + )); + } + apple.verify() +} + +/// Requires a Darwin build's Rust tool root and target directory to be +/// direct children of one recipe-owned parent. +/// +/// The linker strips that parent from every debug-map object path +/// (`-oso_prefix`), so the standard library objects and the build's own +/// objects both become relative. +fn check_recipe_owned_parent( + request: &RecipeRequest, + tools: &SelectedRustTools, +) -> Result<(), Report> { + if tools.apple().is_none() { + return Ok(()); + } + let target_parent = request + .target_dir + .parent() + .ok_or_else(|| recipe_error("Darwin target directory has no parent"))?; + let target_parent = std::fs::canonicalize(target_parent).map_err(|error| { + recipe_error(format!( + "cannot resolve the Darwin target directory's parent: {error}" + )) + })?; + if tools.root().parent() != Some(target_parent.as_path()) { + return Err(recipe_error( + "Darwin Rust tool root and target directory must be direct children of one \ + recipe-owned parent", + )); + } + Ok(()) +} + +/// Rehashes the JS handoff and requires the request to name its directories. +fn resolve_js_handoff( + completed: &JsBuildCompleted, + request: &RecipeRequest, +) -> Result> { + let retained = completed.rehash_for_handoff().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("JS handoff no longer rehashes") + })?; + let retained_root = std::fs::canonicalize(retained) + .map_err(|error| recipe_error(format!("cannot resolve JS handoff: {error}")))?; + if request.js_dir != retained_root.join("bundles") + || request.js_inventory != retained_root.join("tsjs-inventory.json") + { + return Err(recipe_error("request does not name the JS handoff")); + } + Ok(retained_root) +} + +/// Creates the fresh target directory and the private cargo state directories. +fn create_private_roots(request: &RecipeRequest) -> Result<(), Report> { + std::fs::create_dir(&request.target_dir) + .map_err(|error| recipe_error(format!("cannot create fresh target: {error}")))?; + for state in [request.home(), request.cargo_home(), request.tmpdir()] { + std::fs::create_dir(&state) + .map_err(|error| recipe_error(format!("cannot create cargo state: {error}")))?; + } + Ok(()) +} + +/// Re-checks root freshness and disjointness immediately before the target is created. +fn check_fresh_and_disjoint( + request: &RecipeRequest, + ambient: &CheckedAmbient, + tools: &SelectedRustTools, + materials: &PreparedCargoMaterials, + retained_root: &Path, +) -> Result<(), Report> { + prepared_materials("materials root", &request.materials)?; + empty_directory("cargo state root", &request.cargo_state)?; + empty_directory("output root", &request.output)?; + absent_in_real_parent("target directory", &request.target_dir)?; + let checkout = std::fs::canonicalize(ambient.checkout_root()) + .map_err(|error| recipe_error(format!("cannot resolve checkout root: {error}")))?; + let mut roots = vec![ + ("checkout root", checkout.as_path()), + ("Rust tools root", tools.root()), + ("retained JS root", retained_root), + ("materials root", materials.root()), + ("cargo state root", request.cargo_state.as_path()), + ("output root", request.output.as_path()), + ("target directory", request.target_dir.as_path()), + ]; + if let Some(apple) = tools.apple() { + roots.push(("Apple developer directory", apple.developer_dir())); + if let Some(sdk) = apple.owned_sdk_root() { + roots.push(("owned Apple SDK", sdk)); + } + } + disjoint(&roots) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/ambient.rs b/crates/trusted-server-release/src/recipe/ambient.rs new file mode 100644 index 000000000..217f40729 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/ambient.rs @@ -0,0 +1,459 @@ +//! Ambient build conditions: compiled profiles, observations and the +//! canonical/reproduction pairing. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde_json::{Value, json}; + +use super::fresh::{disjoint, normal_absolute}; +use super::{Role, recipe_error}; +use crate::boundary::Umask; +use crate::error::ReleaseError; + +/// Locale every compiler child sees, whatever the host reports. +const NORMALIZED_LOCALE: &str = "C"; +/// Timezone every compiler child sees, whatever the host reports. +const NORMALIZED_TIMEZONE: &str = "UTC"; + +/// A qualified alternate reproduction profile; none exists, so the production +/// reproduction build stays closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QualifiedAlternate {} + +/// One compiled ambient profile an independent build must run under. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AmbientProfile { + checkout_root: &'static str, + execution_user: &'static str, + jobs: u32, + locale: &'static str, + timezone: &'static str, + umask: u32, +} + +impl AmbientProfile { + /// Profile of the canonical build; its symbolic user and root are + /// qualification-selected. + pub(crate) const CANONICAL: Self = Self { + checkout_root: "external-release-A", + execution_user: "qualification-selected", + jobs: 4, + locale: NORMALIZED_LOCALE, + timezone: NORMALIZED_TIMEZONE, + umask: 0o022, + }; + + /// Placeholder of the reproduction build; its alternates await qualification. + pub(crate) const REPRODUCTION_PENDING: Self = Self { + checkout_root: "external-release-B", + execution_user: "qualified-alternate-or-unsupported", + jobs: 2, + locale: "qualification-selected-alternate", + timezone: "qualification-selected-alternate", + umask: 0o027, + }; + + /// Selects the production profile of `role`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Unqualified`] for the reproduction build: its + /// alternate locale, timezone and user have not been qualified. + pub(crate) fn for_role(role: Role) -> Result> { + match role { + Role::Canonical => Ok(Self::CANONICAL), + Role::Reproduction => Err(Report::new(ReleaseError::Unqualified) + .attach("reproduction build alternate ambient profile is not qualified")), + } + } + + /// Accepts only a qualified alternate; production construction stays closed. + pub(crate) fn alternate(qualification: QualifiedAlternate) -> Self { + match qualification {} + } + + /// Selects a fictional alternate for tests without asserting qualification. + #[cfg(test)] + pub(crate) fn alternate_for_tests() -> Self { + Self::alternate_with_host_for_tests("en_US.UTF-8", "Pacific/Auckland") + } + + /// As [`Self::alternate_for_tests`], with an explicit host locale and timezone. + #[cfg(test)] + pub(crate) fn alternate_with_host_for_tests( + locale: &'static str, + timezone: &'static str, + ) -> Self { + Self { + locale, + timezone, + execution_user: "qualification-selected", + ..Self::REPRODUCTION_PENDING + } + } + + /// The compiled profiles `release-tools.json` tracks, by role. + fn tracked() -> [(Role, Self); 2] { + [ + (Role::Canonical, Self::CANONICAL), + (Role::Reproduction, Self::REPRODUCTION_PENDING), + ] + } + + /// Renders the `ambient_profiles` map exactly as `release-tools.json` + /// states it, keyed by each role's [`Role::spec_label`]. + pub(crate) fn tracked_json() -> Value { + Value::Object( + Self::tracked() + .into_iter() + .map(|(role, profile)| (role.spec_label().to_owned(), profile.to_json())) + .collect(), + ) + } + + /// Renders the profile exactly as `release-tools.json` states it. + pub(crate) fn to_json(&self) -> Value { + json!({ + "checkout_root": self.checkout_root, + "execution_user": self.execution_user, + "jobs": self.jobs, + "locale": self.locale, + "timezone": self.timezone, + "umask": format!("{:03o}", self.umask), + }) + } +} + +/// Fails when tracked `ambient_profiles` settings differ from the compiled profiles. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] naming the profile that drifted. +pub(crate) fn check_profile_drift(settings: &Value) -> Result<(), Report> { + for (role, compiled) in AmbientProfile::tracked() { + let name = role.spec_label(); + if settings["ambient_profiles"][name] != compiled.to_json() { + return Err(recipe_error(format!( + "tracked ambient profile {name} differs from the compiled profile" + ))); + } + } + Ok(()) +} + +/// One independently varied or normalized build condition. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, derive_more::Display)] +pub(crate) enum Dimension { + /// Process umask. + Umask, + /// Cargo job count. + Jobs, + /// Checkout root path. + CheckoutRoot, + /// Build start time. + StartTime, + /// Host and compiler locale. + Locale, + /// Host and compiler timezone. + Timezone, + /// Executing user. + ExecutionUser, +} + +impl Dimension { + /// Every dimension a pair must account for. + pub(crate) const ALL: [Self; 7] = [ + Self::Umask, + Self::Jobs, + Self::CheckoutRoot, + Self::StartTime, + Self::Locale, + Self::Timezone, + Self::ExecutionUser, + ]; +} + +/// How one dimension is reported in the canonical/reproduction pair. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Treatment { + Varied, + Normalized, + Unsupported, +} + +/// Conditions an observer reported for one build; carries no authority itself. +#[derive(Clone, Debug)] +pub(crate) struct ObservedAmbient { + /// Process umask of the build, as a numeric mode. + pub(crate) umask: u32, + /// Cargo job count of the build. + pub(crate) jobs: u32, + /// Locale of the host session. + pub(crate) host_locale: String, + /// Timezone of the host session. + pub(crate) host_timezone: String, + /// Locale the compiler child saw. + pub(crate) child_locale: String, + /// Timezone the compiler child saw. + pub(crate) child_timezone: String, + /// Numeric id of the executing user. + pub(crate) execution_uid: u32, + /// Absolute checkout root of the build. + pub(crate) checkout_root: PathBuf, + /// Unix start time of the build in seconds. + pub(crate) started_at_seconds: u64, +} + +/// Observed conditions that match the compiled profile of one role. +#[derive(Clone, Debug)] +pub(crate) struct CheckedAmbient { + role: Role, + profile: AmbientProfile, + observed: ObservedAmbient, +} + +impl CheckedAmbient { + /// Checks `observed` against the profile compiled for `role`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the profile does not belong to + /// the role, or the observed umask, jobs, host locale or timezone differ + /// from it, the compiler child is not normalized, or the checkout root is + /// not an absolute normal path. + pub(crate) fn check( + role: Role, + profile: &AmbientProfile, + observed: ObservedAmbient, + ) -> Result> { + check_profile_role(role, profile)?; + same("umask", &observed.umask, &profile.umask)?; + same("jobs", &observed.jobs, &profile.jobs)?; + same( + "host locale", + &observed.host_locale.as_str(), + &profile.locale, + )?; + same( + "host timezone", + &observed.host_timezone.as_str(), + &profile.timezone, + )?; + same( + "child locale", + &observed.child_locale.as_str(), + &NORMALIZED_LOCALE, + )?; + same( + "child timezone", + &observed.child_timezone.as_str(), + &NORMALIZED_TIMEZONE, + )?; + normal_absolute("checkout root", &observed.checkout_root)?; + Ok(Self { + role, + profile: profile.clone(), + observed, + }) + } + + /// Returns the checked role. + pub(crate) fn role(&self) -> Role { + self.role + } + + /// Returns the Cargo job count the profile fixes. + pub(crate) fn jobs(&self) -> u32 { + self.profile.jobs + } + + /// Returns the umask the profile fixes. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] when the compiled mask is not a + /// valid permission mask. + pub(crate) fn umask(&self) -> Result> { + Umask::new(self.profile.umask) + } + + /// Returns the checkout root this build runs in. + pub(crate) fn checkout_root(&self) -> &Path { + &self.observed.checkout_root + } + + /// Returns the observed conditions that matched the profile. + pub(crate) fn observed(&self) -> &ObservedAmbient { + &self.observed + } +} + +/// Requires an observed value to equal the profile's value. +fn same( + name: &str, + observed: &T, + expected: &T, +) -> Result<(), Report> { + if observed == expected { + return Ok(()); + } + Err(recipe_error(format!( + "observed {name} {observed:?} differs from profile {expected:?}" + ))) +} + +/// Requires the profile to be the runnable one compiled for `role`. +fn check_profile_role(role: Role, profile: &AmbientProfile) -> Result<(), Report> { + let belongs = match role { + Role::Canonical => *profile == AmbientProfile::CANONICAL, + Role::Reproduction => { + *profile != AmbientProfile::REPRODUCTION_PENDING + && profile.umask != AmbientProfile::CANONICAL.umask + && profile.jobs != AmbientProfile::CANONICAL.jobs + && profile.locale != AmbientProfile::CANONICAL.locale + && profile.timezone != AmbientProfile::CANONICAL.timezone + } + }; + if belongs { + return Ok(()); + } + Err(recipe_error(format!( + "ambient profile is not a runnable profile for role {role:?}" + ))) +} + +/// A claimed placement of every dimension, as an attestation would state it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AmbientClaim { + pub(crate) varied: BTreeSet, + pub(crate) normalized: BTreeSet, + pub(crate) unsupported: BTreeSet, +} + +impl AmbientClaim { + /// Returns the only honest placement of the seven dimensions. + pub(crate) fn standard() -> Self { + let place = |treatment| { + Dimension::ALL + .into_iter() + .filter(|dimension| expected_treatment(*dimension) == treatment) + .collect() + }; + Self { + varied: place(Treatment::Varied), + normalized: place(Treatment::Normalized), + unsupported: place(Treatment::Unsupported), + } + } + + fn placements(&self, dimension: Dimension) -> Vec { + [ + (&self.varied, Treatment::Varied), + (&self.normalized, Treatment::Normalized), + (&self.unsupported, Treatment::Unsupported), + ] + .into_iter() + .filter(|(set, _)| set.contains(&dimension)) + .map(|(_, treatment)| treatment) + .collect() + } +} + +fn expected_treatment(dimension: Dimension) -> Treatment { + match dimension { + Dimension::Umask | Dimension::Jobs | Dimension::CheckoutRoot | Dimension::StartTime => { + Treatment::Varied + } + Dimension::Locale | Dimension::Timezone => Treatment::Normalized, + Dimension::ExecutionUser => Treatment::Unsupported, + } +} + +/// Requires every dimension that must vary to vary, and the user to match. +fn check_variation( + canonical: &ObservedAmbient, + reproduction: &ObservedAmbient, +) -> Result<(), Report> { + for (dimension, differs) in [ + (Dimension::Umask, canonical.umask != reproduction.umask), + (Dimension::Jobs, canonical.jobs != reproduction.jobs), + ( + Dimension::StartTime, + canonical.started_at_seconds != reproduction.started_at_seconds, + ), + ] { + if !differs { + return Err(recipe_error(format!( + "{dimension} is identical across the canonical and reproduction builds" + ))); + } + } + disjoint(&[ + ("canonical checkout root", canonical.checkout_root.as_path()), + ( + "reproduction checkout root", + reproduction.checkout_root.as_path(), + ), + ])?; + if canonical.execution_uid != reproduction.execution_uid { + return Err(recipe_error( + "execution user differs but no alternate user is qualified", + )); + } + Ok(()) +} + +/// Requires the claim to place every dimension exactly once, as expected. +fn check_claim(claim: &AmbientClaim) -> Result<(), Report> { + for dimension in Dimension::ALL { + let placements = claim.placements(dimension); + if placements.is_empty() { + return Err(recipe_error(format!("claim omits {dimension}"))); + } + if placements != [expected_treatment(dimension)] { + return Err(recipe_error(format!("claim misreports {dimension}"))); + } + } + Ok(()) +} + +/// A verified canonical/reproduction ambient pairing. +#[derive(Clone, Debug)] +pub(crate) struct AmbientRecord { + claim: AmbientClaim, +} + +impl AmbientRecord { + /// Verifies that the canonical and reproduction builds varied exactly + /// what the claim states. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the roles are not canonical then + /// reproduction, a varied dimension did not vary, checkout roots overlap, + /// the execution user differs without qualification, or the claim omits + /// or misplaces a dimension. + pub(crate) fn pair( + canonical: &CheckedAmbient, + reproduction: &CheckedAmbient, + claim: &AmbientClaim, + ) -> Result> { + if canonical.role != Role::Canonical || reproduction.role != Role::Reproduction { + return Err(recipe_error( + "ambient pair must be the canonical then the reproduction build", + )); + } + check_variation(&canonical.observed, &reproduction.observed)?; + check_claim(claim)?; + Ok(Self { + claim: claim.clone(), + }) + } + + /// Returns the verified placement of every dimension. + pub(crate) fn claim(&self) -> &AmbientClaim { + &self.claim + } +} diff --git a/crates/trusted-server-release/src/recipe/apple.rs b/crates/trusted-server-release/src/recipe/apple.rs new file mode 100644 index 000000000..a43460437 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/apple.rs @@ -0,0 +1,747 @@ +//! The Apple developer tools a Darwin recipe compiles and links C with. +//! +//! The developer directory is a host system root: the macOS boundary allows +//! only reading and executing below it and never scans it. Its identity is +//! therefore this explicit record: the developer directory, the selected SDK, +//! `clang`, `ar` and `ld` selected directly from the default toolchain (never +//! through `xcrun` or the `/usr/bin` shims), the Xcode and SDK versions, and +//! the SHA-256 of the three tools and of `SDKSettings.json`. The record is +//! rehashed at binding and immediately before and after the confined run. +//! +//! # Identity scope +//! +//! Ordinary CI selection independently copies the selected SDK into an owned, +//! bounded tree and binds its digest into this record. Headers, `.tbd` stubs, +//! frameworks, modes and internal aliases in that selected copy are rehashed +//! before and after execution and at evidence sealing. Explicit lexical test +//! fixtures retain only the four-file record and never claim SDK coverage. +//! +//! Ordinary CI selection also measures, in place below the selected `clang`'s +//! toolchain, tree digests of the helper directory, the entire selected +//! `usr/lib` directory and the single versioned clang resource directory, +//! plus the SHA-256 of `libLTO.dylib` and +//! optional `libtapi.dylib`, and rechecks them at every verification point. +//! Which helpers clang actually ran, the dependency/load closure, libraries +//! outside the selected measured directories, host runtime and original +//! acquisition remain unresolved. The developer directory still permits broad +//! read/execute access; neither receipt proves every compiler input came from +//! the measured trees. +//! +//! Only the test-only ordinary CI selection constructs a record; the +//! qualified selection is uninhabited until Task 8 qualifies a toolchain. + +use std::fs::File; +use std::io::Read as _; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +#[cfg(test)] +use std::time::Instant; + +use error_stack::Report; +use serde::Serialize; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::artifact::EXECUTABLE_LIMIT; +use super::fresh::{normal_absolute, real_directory}; +use super::recipe_error; +use super::tools::Authority; +use crate::documents::{Role, canonical_json}; +use crate::error::ReleaseError; +use crate::inspect::MacosVersion; +#[cfg(test)] +use crate::process::Cancellation; +use sdk::{OwnedSdk, SdkReceipt}; +use toolchain::{MeasuredToolchain, ToolchainReceipt}; + +mod sdk; +mod toolchain; + +/// Fixed prefix the C path remap gives the developer directory. +pub(crate) const REMAPPED_DEVELOPER: &str = "/apple-developer"; +/// Fixed prefix for a separately owned SDK snapshot. +pub(crate) const REMAPPED_SDK: &str = "/apple-sdk"; +/// Default toolchain binary directory below the developer directory. +const TOOLCHAIN_BIN: &str = "Toolchains/XcodeDefault.xctoolchain/usr/bin"; +/// macOS SDK alias below the developer directory. +const MACOS_SDK: &str = "Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"; +/// SDK settings file whose `Version` is the measured SDK version. +const SDK_SETTINGS: &str = "SDKSettings.json"; +/// Largest SDK settings or version file read. +const SETTINGS_LIMIT: u64 = 1024 * 1024; +/// Longest accepted Xcode version text. +const MAX_XCODE_VERSION: usize = 32; +/// File read size while hashing a tool. +const HASH_CHUNK: usize = 64 * 1024; + +/// Apple developer tools that passed Task 8 qualification. +/// +/// None have, so this type is uninhabited and production selection stays closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QualifiedAppleTools {} + +/// How the developer tools were selected. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AppleSelection { + /// A qualified production selection. + Qualified(QualifiedAppleTools), + /// An ordinary CI Xcode; test builds only. + #[cfg(test)] + OrdinaryCi, +} + +/// Explicit facts describing one Apple developer directory. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AppleToolFacts { + /// Canonical developer directory, the only system root. + pub(crate) developer_dir: PathBuf, + /// Canonical macOS SDK root below the developer directory. + pub(crate) sdk_root: PathBuf, + /// The C compiler and linker driver. + pub(crate) clang: PathBuf, + /// The archiver. + pub(crate) ar: PathBuf, + /// The linker `clang` runs from its own directory. + pub(crate) ld: PathBuf, + /// Measured Xcode version, such as `16.4`. + pub(crate) xcode_version: String, + /// Measured SDK version from `SDKSettings.json`. + pub(crate) sdk_version: MacosVersion, + /// SHA-256 of `clang`. + pub(crate) clang_sha256: String, + /// SHA-256 of `ar`. + pub(crate) ar_sha256: String, + /// SHA-256 of `ld`. + pub(crate) ld_sha256: String, + /// SHA-256 of the SDK's `SDKSettings.json`. + pub(crate) sdk_settings_sha256: String, +} + +/// A checked Apple developer tool record bound to its selection. +#[derive(Clone, Debug)] +pub(crate) struct AppleDeveloperTools { + facts: AppleToolFacts, + selection: AppleSelection, + sdk: Option>, + toolchain: Option, +} + +/// Candidate identity of the explicitly measured Apple files. +/// +/// The exclusions describe input classes with unresolved coverage. A detached +/// SDK receipt is present only for a measured owned snapshot, and a +/// toolchain receipt only for a measured in-place toolchain. +#[derive(Debug, Serialize)] +pub(crate) struct AppleToolEvidence { + identity_sha256: String, + xcode_version: String, + sdk_version: String, + tools: [AppleFileEvidence; 3], + sdk_settings: AppleFileEvidence, + sdk_tree: Option, + toolchain: Option, + coverage_exclusions: Vec<&'static str>, + origin: &'static str, + qualification: &'static str, +} + +#[derive(Debug, Serialize)] +struct AppleFileEvidence { + role: &'static str, + path: PathBuf, + sha256: String, +} + +impl AppleToolEvidence { + /// Borrows measured tool roles and raw-file digests, excluding private paths. + pub(crate) fn tool_files(&self) -> impl Iterator { + self.tools + .iter() + .map(|file| (file.role, file.sha256.as_str())) + } + /// Returns the SDK settings raw-file digest. + pub(crate) fn sdk_settings_sha256(&self) -> &str { + &self.sdk_settings.sha256 + } + /// Returns the detached SDK's structured-tree digest, when measured. + pub(crate) fn sdk_tree_sha256(&self) -> Option<&str> { + self.sdk_tree.as_ref().map(SdkReceipt::tree_sha256) + } + /// Returns the in-place `libLTO.dylib` raw-file digest, when measured. + pub(crate) fn liblto_sha256(&self) -> Option<&str> { + self.toolchain.as_ref().map(ToolchainReceipt::liblto_sha256) + } + /// Returns the in-place `libtapi.dylib` raw-file digest, when present and measured. + pub(crate) fn libtapi_sha256(&self) -> Option<&str> { + self.toolchain + .as_ref() + .and_then(ToolchainReceipt::libtapi_sha256) + } + /// Returns the clang resource directory's structured-tree digest, when measured. + pub(crate) fn clang_resource_tree_sha256(&self) -> Option<&str> { + self.toolchain + .as_ref() + .map(ToolchainReceipt::clang_resource_tree_sha256) + } + /// Returns the toolchain helper directory's structured-tree digest, when measured. + pub(crate) fn helper_tree_sha256(&self) -> Option<&str> { + self.toolchain + .as_ref() + .map(ToolchainReceipt::helper_tree_sha256) + } + /// Returns the selected toolchain library directory's structured-tree digest, when measured. + pub(crate) fn library_tree_sha256(&self) -> Option<&str> { + self.toolchain + .as_ref() + .map(ToolchainReceipt::library_tree_sha256) + } + /// Returns the SDK version used to check the accepted executable. + pub(crate) fn sdk_version(&self) -> &str { + &self.sdk_version + } + /// Returns the reviewed input classes with unresolved coverage. + pub(crate) fn coverage_exclusions(&self) -> &[&'static str] { + &self.coverage_exclusions + } +} + +fn lower_hex_digest(text: &str) -> bool { + text.len() == 64 + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Requires absolute normal text that can travel unquoted in `CFLAGS`, a +/// `-Wl,` list, a `PATH`-like value and a Cargo configuration string. +fn plain_path(label: &str, path: &Path) -> Result<(), Report> { + normal_absolute(label, path)?; + let plain = path.to_str().is_some_and(|text| { + !text.contains([',', ':', '"', '\\', '=']) + && !text.chars().any(|c| c.is_whitespace() || c.is_control()) + }); + if !plain { + return Err(recipe_error(format!( + "{label} holds whitespace, a separator or a quote" + ))); + } + Ok(()) +} + +/// Requires `path` strictly below `developer_dir` through normal components. +fn below_developer( + label: &str, + path: &Path, + developer_dir: &Path, +) -> Result<(), Report> { + let below = path.strip_prefix(developer_dir).is_ok_and(|relative| { + relative.components().next().is_some() + && relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + }); + if !below { + return Err(recipe_error(format!( + "{label} is not inside the Apple developer directory" + ))); + } + Ok(()) +} + +/// Checks the facts lexically; nothing on disk is read. +fn check_facts(facts: &AppleToolFacts) -> Result<(), Report> { + plain_path("Apple developer directory", &facts.developer_dir)?; + let tools = [ + ("clang", &facts.clang), + ("ar", &facts.ar), + ("ld", &facts.ld), + ]; + for (label, path) in [("Apple SDK root", &facts.sdk_root)] + .into_iter() + .chain(tools) + { + plain_path(label, path)?; + below_developer(label, path, &facts.developer_dir)?; + } + let directory = facts.clang.parent(); + for (name, path) in tools { + if path.file_name().and_then(|file| file.to_str()) != Some(name) + || path.parent() != directory + { + return Err(recipe_error(format!( + "Apple {name} must be named {name} beside clang in one toolchain directory" + ))); + } + } + let version_ok = !facts.xcode_version.is_empty() + && facts.xcode_version.len() <= MAX_XCODE_VERSION + && facts + .xcode_version + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.'); + if !version_ok { + return Err(recipe_error("Xcode version is not dotted decimal text")); + } + let digests = [ + &facts.clang_sha256, + &facts.ar_sha256, + &facts.ld_sha256, + &facts.sdk_settings_sha256, + ]; + if !digests.iter().all(|digest| lower_hex_digest(digest)) { + return Err(recipe_error( + "Apple tool digests must be 64 lowercase hex digits", + )); + } + Ok(()) +} + +/// Hashes a regular file opened without following links, within `limit`. +fn hash_regular(label: &str, path: &Path, limit: u64) -> Result> { + let io = |error: std::io::Error| recipe_error(format!("{label} cannot be hashed: {error}")); + let mut file = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(io)?; + let metadata = file.metadata().map_err(io)?; + if !metadata.is_file() || metadata.len() > limit { + return Err(recipe_error(format!( + "{label} is not a regular file within its size limit" + ))); + } + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; HASH_CHUNK]; + let mut total = 0_u64; + loop { + let read = file.read(&mut buffer).map_err(io)?; + let Some(chunk) = buffer.get(..read).filter(|chunk| !chunk.is_empty()) else { + return Ok(hex::encode(hasher.finalize())); + }; + total += chunk.len() as u64; + if total > limit { + return Err(recipe_error(format!("{label} grew past its size limit"))); + } + hasher.update(chunk); + } +} + +impl AppleDeveloperTools { + /// Exports typed candidate facts after rechecking the measured Apple files. + /// + /// # Errors + /// + /// Rejects changed tools or SDK settings, or invalid relative component paths. + pub(crate) fn candidate_measurement(&self) -> Result> { + self.verify()?; + let file = |role, path: &Path, sha256: &str| { + Ok::<_, Report>(AppleFileEvidence { + role, + path: path + .strip_prefix(&self.facts.developer_dir) + .map_err(|error| { + recipe_error(format!( + "Apple component escapes developer directory: {error}" + )) + })? + .to_owned(), + sha256: sha256.to_owned(), + }) + }; + Ok(AppleToolEvidence { + identity_sha256: self.identity_sha256()?, + xcode_version: self.facts.xcode_version.clone(), + sdk_version: self.facts.sdk_version.to_string(), + tools: [ + file("compiler", &self.facts.clang, &self.facts.clang_sha256)?, + file("archiver", &self.facts.ar, &self.facts.ar_sha256)?, + file("linker", &self.facts.ld, &self.facts.ld_sha256)?, + ], + sdk_settings: if self.sdk.is_some() { + AppleFileEvidence { + role: "sdk_settings", + path: PathBuf::from(SDK_SETTINGS), + sha256: self.facts.sdk_settings_sha256.clone(), + } + } else { + file( + "sdk_settings", + &self.facts.sdk_root.join(SDK_SETTINGS), + &self.facts.sdk_settings_sha256, + )? + }, + sdk_tree: self.sdk.as_ref().map(|sdk| sdk.receipt().clone()), + toolchain: self + .toolchain + .as_ref() + .map(|toolchain| toolchain.receipt().clone()), + coverage_exclusions: self.coverage_exclusions(), + origin: "candidate_measurement", + qualification: "unqualified", + }) + } + + /// Names the reviewed input classes with unresolved coverage. + /// + /// The dependency/load closure and libraries outside selected measured + /// directories remain unresolved, so the list is never empty. + fn coverage_exclusions(&self) -> Vec<&'static str> { + let mut exclusions = Vec::new(); + if self.sdk.is_none() { + exclusions.extend(["sdk_headers", "sdk_stubs", "sdk_frameworks"]); + } + if self.toolchain.is_none() { + exclusions.extend(["tool_helpers", "liblto", "clang_resource_directory"]); + } + exclusions.push("toolchain_libraries"); + exclusions + } + + /// Accepts only a qualified selection; production construction stays closed. + pub(crate) fn qualified(_facts: AppleToolFacts, qualification: QualifiedAppleTools) -> Self { + match qualification {} + } + + /// Records explicit facts as an ordinary CI selection without reading disk. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when a path is not absolute normal + /// plain text, the SDK root or a tool is not strictly inside the + /// developer directory, `clang`, `ar` and `ld` are not so named in one + /// directory, the Xcode version is not dotted decimal, or a digest is not + /// 64 lowercase hex digits. + #[cfg(test)] + pub(crate) fn from_facts_for_tests( + facts: AppleToolFacts, + ) -> Result> { + check_facts(&facts)?; + Ok(Self { + facts, + selection: AppleSelection::OrdinaryCi, + sdk: None, + toolchain: None, + }) + } + + /// Measures the selected toolchain's helpers, entire `usr/lib`, `libLTO`, + /// optional `libtapi` and clang resources in place and binds them into this record. + /// + /// # Errors + /// + /// Rejects changed recorded files, or as the toolchain measurement: + /// ambiguous resource versions, a missing `libLTO`, unsafe entries, + /// exhausted limits, cancellation or expiration. + #[cfg(test)] + pub(crate) fn with_measured_toolchain_for_tests( + mut self, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + self.toolchain = Some(MeasuredToolchain::measure( + &self.facts.clang, + &self.facts.developer_dir, + deadline, + cancellation, + )?); + // One verification after measuring suffices: it rehashes every + // recorded file against the facts (so an earlier change is still + // caught) and also remeasures the new toolchain receipt. A second + // pass before measuring would only rehash the same files again. + self.verify()?; + Ok(self) + } + + /// Stages an explicit test selection through the same detached SDK policy. + /// + /// # Errors + /// + /// Rejects invalid source facts, unsafe trees, changes, cancellation or expiration. + #[cfg(test)] + pub(crate) fn with_detached_sdk_for_tests( + facts: AppleToolFacts, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let selected = Self::stage_detached_sdk(facts, deadline, cancellation)?; + selected.verify()?; + Ok(selected) + } + + /// Stages the detached SDK of an explicit test selection without the + /// trailing full verification. + /// + /// The recorded tools and SDK settings are verified before staging. Only + /// a caller that runs a full [`Self::verify`] next, such as + /// [`Self::measure_selected_developer`] through + /// [`Self::with_measured_toolchain_for_tests`], may skip the trailing + /// pass; every other caller uses [`Self::with_detached_sdk_for_tests`]. + /// + /// # Errors + /// + /// Rejects invalid source facts, unsafe trees, changes, cancellation or expiration. + #[cfg(test)] + fn stage_detached_sdk( + facts: AppleToolFacts, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let mut selected = Self::from_facts_for_tests(facts)?; + selected.verify()?; + let sdk = OwnedSdk::stage(&selected.facts.sdk_root, deadline, cancellation)?; + plain_path("owned Apple SDK root", sdk.root())?; + selected.sdk = Some(Arc::new(sdk)); + Ok(selected) + } + + /// Measures the selected Xcode of an ordinary CI macOS runner. + /// + /// The developer directory is the target of `/var/db/xcode_select_link`, + /// which `xcode-select --switch` maintains; nothing is executed. The SDK + /// version is `SDKSettings.json`'s `Version` and the Xcode version is the + /// `CFBundleShortVersionString` of the bundle's `version.plist`. This + /// measures the host, so only CI runs it. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the selection link, the default + /// toolchain, the macOS SDK or a version cannot be read, or as + /// [`Self::from_facts_for_tests`]. + #[cfg(test)] + pub(crate) fn for_ordinary_ci( + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let io = |error: std::io::Error| recipe_error(format!("cannot measure Xcode: {error}")); + let link = std::fs::read_link("/var/db/xcode_select_link").map_err(io)?; + let developer_dir = std::fs::canonicalize(link).map_err(io)?; + Self::measure_selected_developer(developer_dir, deadline, cancellation) + } + + /// Measures one explicitly selected canonical test developer directory. + /// + /// This is shared only by the strict CI link selection and the ignored + /// local smoke override. Neither grants qualification or acquisition trust. + #[cfg(test)] + fn measure_selected_developer( + developer_dir: PathBuf, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + plain_path("Apple developer directory", &developer_dir)?; + real_directory("Apple developer directory", &developer_dir)?; + let io = |error: std::io::Error| recipe_error(format!("cannot measure Xcode: {error}")); + let sdk_root = std::fs::canonicalize(developer_dir.join(MACOS_SDK)).map_err(io)?; + let settings = read_small(&sdk_root.join(SDK_SETTINGS))?; + let sdk_version = sdk_settings_version(&settings)?; + let bundle = developer_dir + .parent() + .ok_or_else(|| recipe_error("Apple developer directory has no bundle"))?; + let plist = read_small(&bundle.join("version.plist"))?; + let xcode_version = plist_string(&plist, "CFBundleShortVersionString")?; + let bin = developer_dir.join(TOOLCHAIN_BIN); + let (clang, ar, ld) = (bin.join("clang"), bin.join("ar"), bin.join("ld")); + let facts = AppleToolFacts { + clang_sha256: hash_regular("Apple clang", &clang, EXECUTABLE_LIMIT)?, + ar_sha256: hash_regular("Apple ar", &ar, EXECUTABLE_LIMIT)?, + ld_sha256: hash_regular("Apple ld", &ld, EXECUTABLE_LIMIT)?, + sdk_settings_sha256: hex::encode(Sha256::digest(&settings)), + developer_dir, + sdk_root, + clang, + ar, + ld, + xcode_version, + sdk_version, + }; + // The toolchain measurement ends with a full verification that + // rehashes the staged SDK as well, so staging skips its own trailing + // pass over the same tree. + Self::stage_detached_sdk(facts, deadline, cancellation)? + .with_measured_toolchain_for_tests(deadline, cancellation) + } + + /// The authority of this selection. + pub(crate) fn authority(&self) -> Authority { + match self.selection { + AppleSelection::Qualified(qualification) => match qualification {}, + #[cfg(test)] + AppleSelection::OrdinaryCi => Authority::Unqualified, + } + } + + /// The developer directory, the only system root. + pub(crate) fn developer_dir(&self) -> &Path { + &self.facts.developer_dir + } + + /// The macOS SDK root. + pub(crate) fn sdk_root(&self) -> &Path { + self.sdk + .as_ref() + .map_or(&self.facts.sdk_root, |sdk| sdk.root()) + } + + /// The separately owned SDK root, absent for lexical fixture records. + pub(crate) fn owned_sdk_root(&self) -> Option<&Path> { + self.sdk.as_ref().map(|sdk| sdk.root()) + } + + /// The selected `clang`. + pub(crate) fn clang(&self) -> &Path { + &self.facts.clang + } + + /// The selected `ar`. + pub(crate) fn ar(&self) -> &Path { + &self.facts.ar + } + + /// The measured SDK version a linked executable must record. + pub(crate) fn sdk_version(&self) -> MacosVersion { + self.facts.sdk_version + } + + /// Rehashes the tools, SDK settings, detached SDK and measured toolchain + /// against the record. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the developer directory or SDK + /// root is not a real directory, a tool or the settings file is not a + /// regular file, or any digest or toolchain receipt differs. + pub(crate) fn verify(&self) -> Result<(), Report> { + real_directory("Apple developer directory", &self.facts.developer_dir)?; + real_directory("Apple SDK root", self.sdk_root())?; + if let Some(sdk) = &self.sdk { + sdk.verify()?; + } + if let Some(toolchain) = &self.toolchain { + toolchain.verify()?; + } + let settings = self.sdk_root().join(SDK_SETTINGS); + let expected = [ + ( + "Apple clang", + &self.facts.clang, + &self.facts.clang_sha256, + EXECUTABLE_LIMIT, + ), + ( + "Apple ar", + &self.facts.ar, + &self.facts.ar_sha256, + EXECUTABLE_LIMIT, + ), + ( + "Apple ld", + &self.facts.ld, + &self.facts.ld_sha256, + EXECUTABLE_LIMIT, + ), + ( + "Apple SDK settings", + &settings, + &self.facts.sdk_settings_sha256, + SETTINGS_LIMIT, + ), + ]; + for (label, path, digest, limit) in expected { + if hash_regular(label, path, limit)? != *digest { + return Err(recipe_error(format!("{label} changed after measurement"))); + } + } + Ok(()) + } + + /// The identity record as a measurement value. + pub(crate) fn measurement(&self) -> Value { + let text = |path: &Path| path.to_string_lossy().into_owned(); + let mut value = json!({ + "developer_dir": text(&self.facts.developer_dir), + "sdk_root": if self.sdk.is_some() { REMAPPED_SDK.to_owned() } else { text(&self.facts.sdk_root) }, + "clang": text(&self.facts.clang), + "ar": text(&self.facts.ar), + "ld": text(&self.facts.ld), + "xcode_version": self.facts.xcode_version, + "sdk_version": self.facts.sdk_version.to_string(), + "clang_sha256": self.facts.clang_sha256, + "ar_sha256": self.facts.ar_sha256, + "ld_sha256": self.facts.ld_sha256, + "sdk_settings_sha256": self.facts.sdk_settings_sha256, + }); + if let Some(sdk) = &self.sdk { + value["sdk_tree"] = json!(sdk.receipt()); + } + if let Some(toolchain) = &self.toolchain { + value["toolchain"] = json!(toolchain.receipt()); + } + value + } + + /// SHA-256 of the canonical identity record, bound into the plan digest. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the record cannot be encoded. + pub(crate) fn identity_sha256(&self) -> Result> { + let bytes = canonical_json(&self.measurement(), Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + Ok(hex::encode(Sha256::digest(bytes))) + } +} + +/// Reads a small regular file without following links. +#[cfg(test)] +fn read_small(path: &Path) -> Result, Report> { + let io = + |error: std::io::Error| recipe_error(format!("cannot read {}: {error}", path.display())); + let file = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(io)?; + let mut bytes = Vec::new(); + file.take(SETTINGS_LIMIT + 1) + .read_to_end(&mut bytes) + .map_err(io)?; + if bytes.len() as u64 > SETTINGS_LIMIT { + return Err(recipe_error(format!("{} is too large", path.display()))); + } + Ok(bytes) +} + +/// Returns the SDK version `SDKSettings.json` states. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the bytes are not JSON with a +/// dotted decimal `Version`. +pub(crate) fn sdk_settings_version(bytes: &[u8]) -> Result> { + let value: Value = serde_json::from_slice(bytes) + .map_err(|error| recipe_error(format!("SDK settings are not JSON: {error}")))?; + value["Version"] + .as_str() + .and_then(MacosVersion::parse) + .ok_or_else(|| recipe_error("SDK settings hold no dotted decimal Version")) +} + +/// Returns the `` that follows `name` in a property list. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the key or its string is absent or +/// the text is not UTF-8. +pub(crate) fn plist_string(bytes: &[u8], name: &str) -> Result> { + let text = std::str::from_utf8(bytes) + .map_err(|error| recipe_error(format!("property list is not UTF-8: {error}")))?; + let key = format!("{name}"); + let after = text + .find(&key) + .map(|start| text[start + key.len()..].trim_start()) + .and_then(|rest| rest.strip_prefix("")) + .and_then(|rest| rest.split_once("")) + .map(|(value, _)| value.to_owned()); + after.ok_or_else(|| recipe_error(format!("property list holds no string {name}"))) +} diff --git a/crates/trusted-server-release/src/recipe/apple/sdk.rs b/crates/trusted-server-release/src/recipe/apple/sdk.rs new file mode 100644 index 000000000..2a2ef397f --- /dev/null +++ b/crates/trusted-server-release/src/recipe/apple/sdk.rs @@ -0,0 +1,761 @@ +//! Detached, bounded copies of the selected installed Apple SDK. +//! +//! The receipt measures the destination that the recipe actually selects. +//! Copying is not an atomic observation of vendor directory membership and +//! does not authenticate an original distribution or its acquisition. + +use std::ffi::CString; +use std::fs::{self, File}; +use std::io::{Read as _, Write as _}; +use std::mem::MaybeUninit; +use std::os::fd::{AsRawFd as _, FromRawFd as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _, symlink}; +use std::path::{Component, Path, PathBuf}; +use std::time::Instant; + +use error_stack::Report; +use serde::Serialize; + +use super::super::fresh::real_directory; +use super::super::recipe_error; +use crate::error::ReleaseError; +use crate::process::Cancellation; +use crate::tool_tree::tool_tree_digest; + +const FILE_LIMIT: u64 = 512 * 1024 * 1024; +const TREE_LIMIT: u64 = 2 * 1024 * 1024 * 1024; +const ENTRY_LIMIT: usize = 100_000; + +/// Digest of a complete detached SDK tree, constructed only by measurement. +#[derive(Clone, Debug, Serialize)] +pub(super) struct SdkReceipt { + tree_sha256: String, + regular_bytes: u64, + entries: usize, +} + +impl SdkReceipt { + /// Returns the detached SDK's measured structured-tree identity. + pub(super) fn tree_sha256(&self) -> &str { + &self.tree_sha256 + } +} + +/// Private SDK copy whose lifetime is retained by every tool selection clone. +#[derive(Debug)] +pub(super) struct OwnedSdk { + _directory: OwnedDirectory, + root: PathBuf, + receipt: SdkReceipt, + deadline: Instant, + cancellation: Cancellation, +} + +/// Owns cleanup from creation, including errors after source modes are restored. +/// +/// Only created directory paths are recorded, bounded by the SDK entry limit. +/// Cleanup uses root-relative descriptors and never changes file permissions. +#[derive(Debug)] +struct OwnedDirectory { + directory: tempfile::TempDir, + root: Option, + directories: Vec, +} + +impl OwnedDirectory { + fn new(directory: tempfile::TempDir) -> Result> { + let mut owned = Self { + directory, + root: None, + directories: Vec::new(), + }; + owned.root = Some( + File::options() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(owned.directory.path()) + .map_err(io)?, + ); + Ok(owned) + } +} + +/// Restores owner access through checked directory components, never aliases. +fn restore_directory(root: &File, relative: &Path) -> std::io::Result<()> { + let mut parent = root.try_clone()?; + for component in relative.components() { + let Component::Normal(name) = component else { + return Err(std::io::Error::other( + "cleanup path is not relative normal text", + )); + }; + let name = CString::new(name.as_encoded_bytes())?; + let mut metadata = MaybeUninit::::uninit(); + // SAFETY: the descriptor and NUL-terminated name remain valid, and + // fstatat initializes metadata on success without following aliases. + let status = unsafe { + libc::fstatat( + parent.as_raw_fd(), + name.as_ptr(), + metadata.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if status != 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: successful fstatat initialized the entire metadata value. + let metadata = unsafe { metadata.assume_init() }; + if metadata.st_mode & libc::S_IFMT != libc::S_IFDIR { + return Err(std::io::Error::other( + "cleanup component is not a real directory", + )); + } + // SAFETY: the live parent descriptor anchors the relative name; + // AT_SYMLINK_NOFOLLOW prevents a swapped alias changing its target. + if unsafe { + libc::fchmodat( + parent.as_raw_fd(), + name.as_ptr(), + metadata.st_mode | 0o700, + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: openat borrows valid arguments and returns a new descriptor. + // Every component is opened without following a symlink. + let descriptor = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY + | libc::O_DIRECTORY + | libc::O_NOFOLLOW + | libc::O_NONBLOCK + | libc::O_CLOEXEC, + ) + }; + if descriptor < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: openat returned an owned descriptor, transferred exactly once. + parent = unsafe { File::from_raw_fd(descriptor) }; + } + Ok(()) +} + +impl Drop for OwnedDirectory { + fn drop(&mut self) { + if let Some(root) = &self.root { + if let Ok(metadata) = root.metadata() { + let _ = root.set_permissions(fs::Permissions::from_mode(metadata.mode() | 0o700)); + } + // Created parents precede their descendants. Cleanup deliberately + // ignores the attempt budget so cancellation cannot retain copies. + for relative in &self.directories { + let _ = restore_directory(root, relative); + } + } + // TempDir now removes the accessible private tree. Destruction remains + // best effort for external I/O failures; the real SDK smoke observes + // that the copied root is actually gone before reporting its receipt. + } +} + +fn check(deadline: Instant, cancellation: &Cancellation) -> Result<(), Report> { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(recipe_error("Apple SDK snapshot cancelled or timed out")); + } + Ok(()) +} + +fn io(error: impl std::fmt::Display) -> Report { + recipe_error(format!("Apple SDK snapshot: {error}")) +} + +fn stamp(metadata: &fs::Metadata) -> (u64, u64, u64, u32, i64, i64, i64, i64) { + ( + metadata.dev(), + metadata.ino(), + metadata.len(), + metadata.mode(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) +} + +impl OwnedSdk { + /// Copies a selected vendor tree without retaining any vendor hardlinks. + /// + /// # Errors + /// + /// Rejects unsafe aliases, special files, regular-file mutation during + /// copying, exhausted limits, cancellation and expiration. The destination + /// must pass the strict owned tool tree policy before it can become selected. + /// This installed-content copy is not an atomic vendor-source observation + /// or an authenticated distribution snapshot. + pub(super) fn stage( + source: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + Self::stage_with_limits( + source, + deadline, + cancellation, + (FILE_LIMIT, TREE_LIMIT, ENTRY_LIMIT), + ) + } + + fn stage_with_limits( + source: &Path, + deadline: Instant, + cancellation: &Cancellation, + limits: (u64, u64, usize), + ) -> Result> { + check(deadline, cancellation)?; + let source = fs::canonicalize(source).map_err(io)?; + real_directory("source Apple SDK", &source)?; + let mut directory = OwnedDirectory::new( + tempfile::Builder::new() + .prefix("ts-apple-sdk-") + .tempdir() + .map_err(io)?, + )?; + let root = fs::canonicalize(directory.directory.path()).map_err(io)?; + let mut pending = vec![source.clone()]; + let mut directories = Vec::new(); + let mut entries = 0; + let mut total = 0; + let mut buffer = vec![0; 64 * 1024]; + while let Some(parent) = pending.pop() { + check(deadline, cancellation)?; + real_directory("source Apple SDK directory", &parent)?; + for entry in fs::read_dir(parent).map_err(io)? { + check(deadline, cancellation)?; + let path = entry.map_err(io)?.path(); + let relative = path + .strip_prefix(&source) + .map_err(|error| recipe_error(error.to_string()))?; + if relative.to_str().is_none() { + return Err(recipe_error("Apple SDK path is not UTF-8")); + } + entries += 1; + if entries > limits.2 { + return Err(recipe_error("Apple SDK exceeds entry limit")); + } + let destination = root.join(relative); + let metadata = fs::symlink_metadata(&path).map_err(io)?; + if metadata.is_dir() { + fs::create_dir(&destination).map_err(io)?; + directory.directories.push(relative.to_owned()); + directories.push((destination, metadata.permissions())); + pending.push(path); + } else if metadata.is_file() { + if metadata.len() > limits.0 || total + metadata.len() > limits.1 { + return Err(recipe_error("Apple SDK exceeds byte limit")); + } + let mut input = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(&path) + .map_err(io)?; + let opened = input.metadata().map_err(io)?; + if !opened.is_file() || stamp(&opened) != stamp(&metadata) { + return Err(recipe_error("Apple SDK source changed before copying")); + } + let mut output = File::options() + .write(true) + .create_new(true) + .custom_flags(libc::O_NOFOLLOW) + .open(&destination) + .map_err(io)?; + let mut size = 0; + loop { + check(deadline, cancellation)?; + let read = input.read(&mut buffer).map_err(io)?; + if read == 0 { + break; + } + size += read as u64; + total += read as u64; + if size > limits.0 || total > limits.1 { + return Err(recipe_error("Apple SDK grew beyond byte limit")); + } + output.write_all(&buffer[..read]).map_err(io)?; + } + if size != metadata.len() + || stamp(&input.metadata().map_err(io)?) != stamp(&metadata) + || stamp(&fs::symlink_metadata(&path).map_err(io)?) != stamp(&metadata) + { + return Err(recipe_error("Apple SDK source changed while copying")); + } + output.set_permissions(metadata.permissions()).map_err(io)?; + } else if metadata.file_type().is_symlink() { + let target = fs::read_link(&path).map_err(io)?; + if target.is_absolute() + || target.to_str().is_none() + || !fs::canonicalize(&path).map_err(io)?.starts_with(&source) + { + return Err(recipe_error("Apple SDK alias escapes source root")); + } + symlink(target, destination).map_err(io)?; + } else { + return Err(recipe_error("Apple SDK contains a special file")); + } + } + } + for (path, permissions) in directories.into_iter().rev() { + check(deadline, cancellation)?; + fs::set_permissions(path, permissions).map_err(io)?; + } + fs::set_permissions(&root, fs::metadata(&source).map_err(io)?.permissions()).map_err(io)?; + let tree_sha256 = tool_tree_digest(&root, &|| check(deadline, cancellation))?; + Ok(Self { + _directory: directory, + root, + receipt: SdkReceipt { + tree_sha256, + regular_bytes: total, + entries, + }, + deadline, + cancellation: cancellation.clone(), + }) + } + + /// The exact owned directory selected as SDKROOT. + pub(super) fn root(&self) -> &Path { + &self.root + } + + /// The receipt measured from the private destination tree. + pub(super) fn receipt(&self) -> &SdkReceipt { + &self.receipt + } + + /// Rehashes the owned SDK under the caller's original attempt budget. + /// + /// # Errors + /// + /// Rejects changed or unsafe entries, cancellation and expiration. + pub(super) fn verify(&self) -> Result<(), Report> { + check(self.deadline, &self.cancellation)?; + real_directory("owned Apple SDK", &self.root)?; + let digest = tool_tree_digest(&self.root, &|| check(self.deadline, &self.cancellation))?; + if digest != self.receipt.tree_sha256 { + return Err(recipe_error("owned Apple SDK changed after measurement")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::fs; + use std::os::unix::fs::symlink; + use std::sync::Arc; + use std::time::{Duration, Instant}; + + use super::*; + use crate::process::Cancellation; + + #[test] + fn explicit_smoke_developer_selection_requires_a_canonical_plain_directory() { + let directory = tempfile::tempdir().expect("should create selection fixture"); + let canonical = + fs::canonicalize(directory.path()).expect("should resolve selection fixture"); + symlink(&canonical, canonical.join("alias")).expect("should create developer alias"); + for path in [PathBuf::from("relative-developer"), canonical.join("alias")] { + let error = super::super::AppleDeveloperTools::measure_selected_developer( + path, + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect_err("should reject unchecked developer root before reading SDK"); + let text = format!("{error:?}"); + assert!( + text.contains("absolute normal path") || text.contains("symlink"), + "should reject root itself rather than missing SDK: {text}" + ); + } + } + + #[allow( + clippy::needless_pass_by_value, + reason = "the failed finalizer owns the SDK to exercise cleanup on its error return" + )] + fn fail_after_restoring_modes(sdk: OwnedSdk) -> Result<(), Report> { + fs::set_permissions( + sdk.root().join("readonly/inaccessible"), + fs::Permissions::from_mode(0o0), + ) + .expect("should restore inaccessible child mode"); + fs::set_permissions( + sdk.root().join("readonly"), + fs::Permissions::from_mode(0o555), + ) + .expect("should restore readonly child mode"); + fs::set_permissions(sdk.root(), fs::Permissions::from_mode(0o0)) + .expect("should restore inaccessible root mode"); + Err(recipe_error("finalization failed after mode restoration")) + } + + #[test] + fn readonly_sdk_directory_is_removed_after_last_clone_even_when_cancelled() { + let source = tempfile::tempdir().expect("should create source"); + fs::create_dir(source.path().join("readonly")).expect("should create readonly directory"); + fs::write(source.path().join("readonly/header.h"), b"header").expect("should write header"); + fs::set_permissions( + source.path().join("readonly"), + fs::Permissions::from_mode(0o555), + ) + .expect("should set readonly mode"); + let cancellation = Cancellation::default(); + let sdk = Arc::new( + OwnedSdk::stage( + source.path(), + Instant::now() + Duration::from_secs(30), + &cancellation, + ) + .expect("should copy readonly SDK"), + ); + fs::set_permissions( + source.path().join("readonly"), + fs::Permissions::from_mode(0o755), + ) + .expect("should restore source for fixture cleanup"); + let root = sdk.root().to_owned(); + let clone = Arc::clone(&sdk); + drop(sdk); + assert!(root.exists(), "should retain SDK while clone exists"); + assert_eq!( + fs::metadata(root.join("readonly")) + .expect("should inspect readonly directory") + .mode() + & 0o7777, + 0o555, + "should preserve measured mode while alive" + ); + cancellation.cancel(); + drop(clone); + let leaked = root.exists(); + if leaked { + fs::set_permissions(root.join("readonly"), fs::Permissions::from_mode(0o755)) + .expect("should clean regression failure"); + fs::remove_dir_all(&root).expect("should remove regression failure"); + } + assert!( + !leaked, + "should remove readonly SDK after last clone despite cancellation" + ); + } + + #[test] + fn failed_stage_scope_removes_restored_readonly_directories() { + let source = tempfile::tempdir().expect("should create source"); + fs::create_dir_all(source.path().join("readonly/inaccessible")) + .expect("should create directory"); + fs::write( + source.path().join("readonly/inaccessible/header.h"), + b"header", + ) + .expect("should write header"); + let sdk = OwnedSdk::stage( + source.path(), + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should copy SDK before fallible finalization"); + let root = sdk.root().to_owned(); + let failed = fail_after_restoring_modes(sdk); + assert!(failed.is_err(), "should exercise failed finalization"); + let leaked = root.exists(); + if leaked { + fs::set_permissions(&root, fs::Permissions::from_mode(0o755)) + .expect("should clean inaccessible regression failure"); + fs::set_permissions(root.join("readonly"), fs::Permissions::from_mode(0o755)) + .expect("should clean readonly regression failure"); + fs::set_permissions( + root.join("readonly/inaccessible"), + fs::Permissions::from_mode(0o755), + ) + .expect("should clean inaccessible child regression failure"); + fs::remove_dir_all(&root).expect("should remove regression failure"); + } + assert!( + !leaked, + "should clean failed stage after modes were restored" + ); + } + + #[test] + fn cleanup_never_follows_replaced_directory_aliases_or_chmods_files() { + let source = tempfile::tempdir().expect("should create source"); + fs::create_dir(source.path().join("directory")).expect("should create directory"); + let external = tempfile::tempdir().expect("should create external fixture"); + fs::write(external.path().join("header.h"), b"external header") + .expect("should write external file"); + fs::set_permissions( + external.path().join("header.h"), + fs::Permissions::from_mode(0o444), + ) + .expect("should protect external file"); + let sdk = OwnedSdk::stage( + source.path(), + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should stage SDK"); + let root = sdk.root().to_owned(); + fs::remove_dir(root.join("directory")).expect("should replace owned directory"); + symlink(external.path(), root.join("directory")).expect("should add external alias"); + fs::hard_link(external.path().join("header.h"), root.join("hardlink.h")) + .expect("should add external file hardlink"); + fs::set_permissions(external.path(), fs::Permissions::from_mode(0o555)) + .expect("should protect external directory"); + drop(sdk); + let mode = fs::metadata(external.path()) + .expect("should inspect external directory") + .mode() + & 0o7777; + let file_mode = fs::metadata(external.path().join("header.h")) + .expect("should inspect external file") + .mode() + & 0o7777; + fs::set_permissions(external.path(), fs::Permissions::from_mode(0o755)) + .expect("should restore external fixture for cleanup"); + assert!( + !root.exists(), + "should remove aliases and hardlinks from owned tree" + ); + assert_eq!(mode, 0o555, "should not chmod external alias target"); + assert_eq!( + file_mode, 0o444, + "should not chmod external hardlinked file" + ); + } + + #[test] + #[ignore = "requires the explicitly selected local Xcode SDK and a receipt destination; copies the SDK, never modifies Xcode"] + fn ordinary_ci_selected_sdk_detached_snapshot_smoke() { + let destination = std::env::var_os("TS_RELEASE_APPLE_SDK_SMOKE_RECEIPT") + .map(PathBuf::from) + .expect("should explicitly supply a fresh smoke receipt destination"); + let cancellation = Cancellation::default(); + let deadline = Instant::now() + Duration::from_secs(15 * 60); + // This explicit local observation never changes CI link selection or + // grants qualification to the measured installed developer tools. + let apple = match std::env::var_os("TS_RELEASE_APPLE_SDK_SMOKE_DEVELOPER_ROOT") { + Some(root) => super::super::AppleDeveloperTools::measure_selected_developer( + PathBuf::from(root), + deadline, + &cancellation, + ), + None => super::super::AppleDeveloperTools::for_ordinary_ci(deadline, &cancellation), + } + .expect("should measure Xcode and stage the selected SDK"); + let root = apple.sdk_root().to_owned(); + let evidence = apple + .candidate_measurement() + .expect("should recheck selected SDK at evidence sealing"); + let receipt = serde_json::to_vec( + &evidence + .sdk_tree + .expect("should have a complete selected SDK receipt"), + ) + .expect("should serialize SDK receipt"); + let clone = apple.clone(); + drop(apple); + clone + .verify() + .expect("should retain and recheck selected SDK through clone"); + drop(clone); + assert!( + !root.exists(), + "should clean copied SDK after last selection is dropped" + ); + let mut output = File::options() + .write(true) + .create_new(true) + .open(destination) + .expect("should create fresh smoke receipt"); + output + .write_all(&receipt) + .expect("should write observed SDK receipt after cleanup"); + } + + #[test] + fn snapshot_copies_vendor_hardlinks_independently_and_preserves_internal_aliases() { + let source = tempfile::tempdir().expect("should create source"); + fs::write(source.path().join("header.h"), b"header").expect("should write header"); + fs::hard_link( + source.path().join("header.h"), + source.path().join("other.h"), + ) + .expect("should link vendor file"); + symlink("header.h", source.path().join("alias.h")).expect("should link alias"); + let sdk = OwnedSdk::stage( + source.path(), + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should stage vendor SDK"); + for name in ["header.h", "other.h"] { + assert_eq!( + fs::metadata(sdk.root().join(name)) + .expect("should inspect copy") + .nlink(), + 1, + "should create independent owned files" + ); + } + assert_eq!( + fs::read_link(sdk.root().join("alias.h")).expect("should inspect alias"), + std::path::PathBuf::from("header.h"), + "should preserve relative alias" + ); + sdk.verify().expect("should verify owned tree"); + } + + #[test] + fn snapshot_refuses_cancelled_or_expired_source_copy() { + let source = tempfile::tempdir().expect("should create source"); + let cancellation = Cancellation::default(); + cancellation.cancel(); + assert!( + OwnedSdk::stage( + source.path(), + Instant::now() + Duration::from_secs(30), + &cancellation + ) + .is_err(), + "should stop cancelled copy" + ); + assert!( + OwnedSdk::stage( + source.path(), + Instant::now() - Duration::from_secs(1), + &Cancellation::default() + ) + .is_err(), + "should stop expired copy" + ); + } + + #[test] + fn snapshot_preserves_source_modes_and_checks_empty_tree_cancellation() { + let source = tempfile::tempdir().expect("should create source"); + fs::set_permissions(source.path(), fs::Permissions::from_mode(0o755)) + .expect("should set source mode"); + let cancellation = Cancellation::default(); + let sdk = OwnedSdk::stage( + source.path(), + Instant::now() + Duration::from_secs(30), + &cancellation, + ) + .expect("should stage source"); + assert_eq!( + fs::metadata(sdk.root()) + .expect("should inspect root") + .mode() + & 0o7777, + 0o755, + "should preserve source root mode" + ); + cancellation.cancel(); + assert!( + sdk.verify().is_err(), + "should check cancellation even for empty tree" + ); + } + + #[test] + fn snapshot_rejects_unsafe_source_aliases_and_special_files() { + for target in ["/absolute", "../outside", "missing"] { + let source = tempfile::tempdir().expect("should create source"); + symlink(target, source.path().join("alias")).expect("should write alias"); + assert!( + OwnedSdk::stage( + source.path(), + Instant::now() + Duration::from_secs(30), + &Cancellation::default() + ) + .is_err(), + "should reject {target}" + ); + } + let source = tempfile::tempdir().expect("should create source"); + let fifo = CString::new( + source + .path() + .join("fifo") + .to_str() + .expect("should have UTF-8 path"), + ) + .expect("should encode FIFO path"); + // SAFETY: the CString remains alive and points to a NUL-terminated path. + assert_eq!( + unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, + 0, + "should create special file" + ); + assert!( + OwnedSdk::stage( + source.path(), + Instant::now() + Duration::from_secs(30), + &Cancellation::default() + ) + .is_err(), + "should reject special file" + ); + } + + #[test] + fn snapshot_enforces_source_file_tree_and_entry_bounds() { + for limits in [(3, 20, 10), (20, 5, 10), (20, 20, 1)] { + let source = tempfile::tempdir().expect("should create source"); + for name in ["first", "second"] { + fs::write(source.path().join(name), b"1234").expect("should write input"); + } + assert!( + OwnedSdk::stage_with_limits( + source.path(), + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + limits + ) + .is_err(), + "should reject exhausted {limits:?}" + ); + } + } + + #[test] + fn snapshot_rejects_destination_hardlinks_and_changed_aliases() { + for hardlink in [true, false] { + let source = tempfile::tempdir().expect("should create source"); + fs::write(source.path().join("header.h"), b"header").expect("should write header"); + symlink("header.h", source.path().join("alias.h")).expect("should write alias"); + let sdk = OwnedSdk::stage( + source.path(), + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should stage source"); + if hardlink { + fs::hard_link(sdk.root().join("header.h"), source.path().join("outside.h")) + .expect("should add external hardlink"); + } else { + fs::remove_file(sdk.root().join("alias.h")).expect("should remove alias"); + symlink("../outside", sdk.root().join("alias.h")).expect("should change alias"); + } + assert!(sdk.verify().is_err(), "should reject mutated owned SDK"); + } + } +} diff --git a/crates/trusted-server-release/src/recipe/apple/toolchain.rs b/crates/trusted-server-release/src/recipe/apple/toolchain.rs new file mode 100644 index 000000000..e4a066298 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/apple/toolchain.rs @@ -0,0 +1,512 @@ +//! In-place identity of the selected toolchain's helpers, `libLTO`, optional +//! `libtapi`, clang resources and the entire library directory. +//! +//! Everything is read below the selected `clang`'s toolchain `usr` directory, +//! which stays part of the vendor developer root: nothing is copied, executed +//! or resolved through `xcrun`. Vendor hard links are accepted because the +//! tree is not owned; every measured file is rehashed at each verification +//! point instead. The receipt states which bytes were present, not which of +//! them clang actually read or ran. Libraries outside the selected directories +//! and the dependency/load closure remain unresolved. +//! +//! # Swap window +//! +//! Only the leaf of each measured file is opened with `O_NOFOLLOW`; the +//! intermediate directories are resolved by path. Between the canonical-root +//! check and a later open, a process able to write the vendor tree could swap +//! an intermediate directory for an alias. Such a change is detected, not +//! prevented: each file's device, inode, size, mode and times must match +//! before and after hashing, every root is rechecked as a real unaliased +//! directory, and the whole receipt is remeasured at each verification +//! point. Inside the confined build the developer directory is a read and +//! execute only system root, so the build itself cannot perform the swap. +//! +//! [`vendor_tree_digest`] requires a canonical root (a real directory that +//! resolves to itself); alias containment is checked by comparing canonical +//! targets against that root, which is only meaningful when the root is +//! canonical. + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{ErrorKind, Read as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Component, Path, PathBuf}; +use std::time::Instant; + +use error_stack::Report; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +use super::super::fresh::real_directory; +use super::super::recipe_error; +use crate::error::ReleaseError; +use crate::process::Cancellation; + +/// Toolchain library directory relative to the toolchain `usr` directory. +const TOOLCHAIN_LIB: &str = "lib"; +/// The LTO library `ld` loads, relative to the toolchain `usr` directory. +const LIBLTO: &str = "lib/libLTO.dylib"; +/// Optional TAPI library relative to the selected toolchain `usr` directory. +const LIBTAPI: &str = "lib/libtapi.dylib"; +/// Parent of clang's versioned resource directory below the toolchain `usr`. +const CLANG_RESOURCES: &str = "lib/clang"; +/// Largest accepted `libLTO.dylib`. +const LIBLTO_LIMIT: u64 = 512 * 1024 * 1024; +/// Largest accepted `libtapi.dylib`. +const LIBTAPI_LIMIT: u64 = 128 * 1024 * 1024; +/// Most entries accepted in the clang resource parent directory. +const RESOURCE_PARENT_ENTRY_LIMIT: usize = 64; +/// Longest accepted clang resource version or alias name. +const MAX_RESOURCE_VERSION: usize = 32; +/// File read size while hashing a vendor file. +const HASH_CHUNK: usize = 64 * 1024; +/// Versioned domain prefix of every vendor tree digest. +const TREE_DOMAIN: &[u8] = b"ts-apple-vendor-tree-v1\0"; + +/// The kind label and bounds of one in-place vendor tree. +/// +/// The kind is hashed into the digest, so trees of different kinds never +/// share an identity even with identical contents. +#[derive(Clone, Copy, Debug)] +struct TreePolicy { + kind: &'static str, + file_bytes: u64, + tree_bytes: u64, + entries: usize, +} + +/// Policy for the clang resource directory (headers and runtime libraries). +const RESOURCE_POLICY: TreePolicy = TreePolicy { + kind: "clang-resource-directory", + file_bytes: 128 * 1024 * 1024, + tree_bytes: 1024 * 1024 * 1024, + entries: 20_000, +}; + +/// Policy for the toolchain helper directory beside `clang`. +const HELPER_POLICY: TreePolicy = TreePolicy { + kind: "toolchain-helper-directory", + file_bytes: 512 * 1024 * 1024, + tree_bytes: 4 * 1024 * 1024 * 1024, + entries: 4_000, +}; + +/// Policy for the selected toolchain's entire `usr/lib` directory. +const LIBRARY_POLICY: TreePolicy = TreePolicy { + kind: "toolchain-library-directory", + file_bytes: 512 * 1024 * 1024, + tree_bytes: 4 * 1024 * 1024 * 1024, + entries: 20_000, +}; + +/// Structured digest of one vendor tree measured in place. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(super) struct VendorTreeReceipt { + tree_sha256: String, + regular_bytes: u64, + entries: usize, +} + +/// Measured toolchain inputs beyond `clang`, `ar` and `ld`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(super) struct ToolchainReceipt { + clang_resource_version: String, + clang_resource_aliases: Vec, + clang_resource_tree: VendorTreeReceipt, + liblto_sha256: String, + libtapi_sha256: Option, + helper_tree: VendorTreeReceipt, + library_tree: VendorTreeReceipt, +} + +impl ToolchainReceipt { + /// Returns the clang resource directory's structured-tree digest. + pub(super) fn clang_resource_tree_sha256(&self) -> &str { + &self.clang_resource_tree.tree_sha256 + } + /// Returns the raw-file digest of `libLTO.dylib`. + pub(super) fn liblto_sha256(&self) -> &str { + &self.liblto_sha256 + } + /// Returns the raw-file digest of `libtapi.dylib`, when present. + pub(super) fn libtapi_sha256(&self) -> Option<&str> { + self.libtapi_sha256.as_deref() + } + /// Returns the helper directory's structured-tree digest. + pub(super) fn helper_tree_sha256(&self) -> &str { + &self.helper_tree.tree_sha256 + } + /// Returns the entire library directory's structured-tree digest. + pub(super) fn library_tree_sha256(&self) -> &str { + &self.library_tree.tree_sha256 + } +} + +/// A toolchain receipt bound to the attempt budget that rechecks it. +#[derive(Clone, Debug)] +pub(super) struct MeasuredToolchain { + helper_dir: PathBuf, + usr_dir: PathBuf, + receipt: ToolchainReceipt, + deadline: Instant, + cancellation: Cancellation, +} + +impl MeasuredToolchain { + /// Measures helpers, resources and the entire selected `usr/lib` in place. + /// + /// The toolchain holds `clang` strictly below `developer_dir`. + /// + /// # Errors + /// + /// Rejects a `clang` path with a non-normal component or not at + /// `/usr/bin/clang`, a toolchain outside the developer + /// directory, anything other than exactly one clang resource version + /// directory, a missing or non-regular `libLTO.dylib`, unsafe aliases, + /// special files, exhausted limits, cancellation and expiration. + pub(super) fn measure( + clang: &Path, + developer_dir: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let (helper_dir, usr_dir) = toolchain_layout(clang, developer_dir)?; + let receipt = measure_receipt(&helper_dir, &usr_dir, &|| budget(deadline, cancellation))?; + Ok(Self { + helper_dir, + usr_dir, + receipt, + deadline, + cancellation: cancellation.clone(), + }) + } + + /// The receipt measured at selection. + pub(super) fn receipt(&self) -> &ToolchainReceipt { + &self.receipt + } + + /// Remeasures the toolchain under the original attempt budget. + /// + /// # Errors + /// + /// Rejects any changed, added or removed entry, a changed resource + /// selection, unsafe entries, cancellation and expiration. + pub(super) fn verify(&self) -> Result<(), Report> { + let current = measure_receipt(&self.helper_dir, &self.usr_dir, &|| { + budget(self.deadline, &self.cancellation) + })?; + if current != self.receipt { + return Err(recipe_error("Apple toolchain changed after measurement")); + } + Ok(()) + } +} + +fn budget(deadline: Instant, cancellation: &Cancellation) -> Result<(), Report> { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(recipe_error( + "Apple toolchain measurement cancelled or timed out", + )); + } + Ok(()) +} + +fn measurement_error(error: impl std::fmt::Display) -> Report { + recipe_error(format!("Apple toolchain measurement: {error}")) +} + +/// Returns the helper (`usr/bin`) and `usr` directories of `clang`. +/// +/// The path must be absolute with only normal components, `clang` must sit +/// in a directory named `bin` whose parent is named `usr`, and that `usr` +/// directory must be strictly inside `developer_dir`. +fn toolchain_layout( + clang: &Path, + developer_dir: &Path, +) -> Result<(PathBuf, PathBuf), Report> { + let mut components = clang.components(); + let normal = components.next() == Some(Component::RootDir) + && components.all(|component| matches!(component, Component::Normal(_))); + if !normal { + return Err(recipe_error( + "Apple clang path must be absolute with only normal components", + )); + } + let helper_dir = clang + .parent() + .filter(|bin| bin.file_name().is_some_and(|name| name == "bin")) + .ok_or_else(|| recipe_error("Apple clang must sit in a toolchain usr/bin directory"))?; + let usr_dir = helper_dir + .parent() + .filter(|usr| usr.file_name().is_some_and(|name| name == "usr")) + .ok_or_else(|| recipe_error("Apple clang must sit in a toolchain usr/bin directory"))?; + if !usr_dir.starts_with(developer_dir) || usr_dir == developer_dir { + return Err(recipe_error( + "Apple toolchain is not inside the Apple developer directory", + )); + } + Ok((helper_dir.to_owned(), usr_dir.to_owned())) +} + +fn measure_receipt( + helper_dir: &Path, + usr_dir: &Path, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + check()?; + real_directory("Apple toolchain helper directory", helper_dir)?; + real_directory( + "Apple toolchain library directory", + &usr_dir.join(TOOLCHAIN_LIB), + )?; + let resource_parent = usr_dir.join(CLANG_RESOURCES); + let (clang_resource_version, clang_resource_aliases) = + select_resource_version(&resource_parent, check)?; + let resource_dir = resource_parent.join(&clang_resource_version); + real_directory("Apple clang resource directory", &resource_dir)?; + let clang_resource_tree = vendor_tree_digest(&resource_dir, RESOURCE_POLICY, check)?; + let (liblto_sha256, _) = hash_vendor_file(&usr_dir.join(LIBLTO), LIBLTO_LIMIT, check)?; + let libtapi_sha256 = optional_libtapi_digest(&usr_dir.join(LIBTAPI), check)?; + let helper_tree = vendor_tree_digest(helper_dir, HELPER_POLICY, check)?; + let library_tree = vendor_tree_digest(&usr_dir.join(TOOLCHAIN_LIB), LIBRARY_POLICY, check)?; + Ok(ToolchainReceipt { + clang_resource_version, + clang_resource_aliases, + clang_resource_tree, + liblto_sha256, + libtapi_sha256, + helper_tree, + library_tree, + }) +} + +/// Records absence only when the exact leaf does not exist; aliases and all +/// other metadata or read failures are rejected rather than treated as absent. +fn optional_libtapi_digest( + path: &Path, + check: &dyn Fn() -> Result<(), Report>, +) -> Result, Report> { + check()?; + match fs::symlink_metadata(path) { + Ok(_) => hash_vendor_file(path, LIBTAPI_LIMIT, check).map(|(digest, _)| Some(digest)), + Err(error) if error.kind() == ErrorKind::NotFound => { + check()?; + let parent = path + .parent() + .ok_or_else(|| recipe_error("Apple libtapi path has no library directory"))?; + real_directory("Apple toolchain library directory", parent)?; + Ok(None) + } + Err(error) => Err(measurement_error(error)), + } +} + +fn resource_version_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= MAX_RESOURCE_VERSION + && name + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + && name + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_digit()) +} + +/// Selects the only real clang version directory and its sibling aliases. +/// +/// Every alias must name exactly that directory; any other entry fails closed. +fn select_resource_version( + parent: &Path, + check: &dyn Fn() -> Result<(), Report>, +) -> Result<(String, Vec), Report> { + real_directory("Apple clang resource parent", parent)?; + let mut versions = Vec::new(); + let mut aliases = Vec::new(); + for (index, entry) in fs::read_dir(parent).map_err(measurement_error)?.enumerate() { + check()?; + if index >= RESOURCE_PARENT_ENTRY_LIMIT { + return Err(recipe_error( + "Apple clang resource parent exceeds entry limit", + )); + } + let entry = entry.map_err(measurement_error)?; + let name = entry + .file_name() + .into_string() + .ok() + .filter(|name| resource_version_name(name)) + .ok_or_else(|| recipe_error("Apple clang resource entry is not a version name"))?; + let metadata = fs::symlink_metadata(entry.path()).map_err(measurement_error)?; + if metadata.is_dir() { + versions.push(name); + } else if metadata.file_type().is_symlink() { + aliases.push(( + name, + fs::read_link(entry.path()).map_err(measurement_error)?, + )); + } else { + return Err(recipe_error( + "Apple clang resource parent holds a non-directory entry", + )); + } + } + let [version] = <[String; 1]>::try_from(versions).map_err(|_| { + recipe_error("Apple clang resources must hold exactly one version directory") + })?; + let mut alias_names = Vec::with_capacity(aliases.len()); + for (name, target) in aliases { + if target != Path::new(&version) { + return Err(recipe_error( + "Apple clang resource alias names another target than the version directory", + )); + } + alias_names.push(name); + } + alias_names.sort(); + Ok((version, alias_names)) +} + +fn stamp(metadata: &fs::Metadata) -> (u64, u64, u64, u32, i64, i64, i64, i64) { + ( + metadata.dev(), + metadata.ino(), + metadata.len(), + metadata.mode(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) +} + +/// Hashes one vendor regular file in place, opened without following a leaf +/// alias. Vendor hard links are accepted; the content is rechecked instead. +fn hash_vendor_file( + path: &Path, + limit: u64, + check: &dyn Fn() -> Result<(), Report>, +) -> Result<(String, u64), Report> { + check()?; + let mut file = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(measurement_error)?; + let before = file.metadata().map_err(measurement_error)?; + if !before.is_file() || before.len() > limit { + return Err(recipe_error(format!( + "Apple toolchain file {} is not a regular file within its size limit", + path.display() + ))); + } + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; HASH_CHUNK]; + let mut size = 0_u64; + loop { + check()?; + let read = match file.read(&mut buffer) { + Ok(read) => read, + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) => return Err(measurement_error(error)), + }; + let Some(chunk) = buffer.get(..read).filter(|chunk| !chunk.is_empty()) else { + break; + }; + size += u64::try_from(chunk.len()).map_err(measurement_error)?; + if size > before.len() { + return Err(recipe_error("Apple toolchain file grew while hashing")); + } + hasher.update(chunk); + } + if size != before.len() + || stamp(&file.metadata().map_err(measurement_error)?) != stamp(&before) + || stamp(&fs::symlink_metadata(path).map_err(measurement_error)?) != stamp(&before) + { + return Err(recipe_error("Apple toolchain file changed while hashing")); + } + Ok((hex::encode(hasher.finalize()), size)) +} + +/// Digests every entry below `root` in place: relative path, kind, mode and, +/// for regular files, size and SHA-256; for aliases, their relative target. +/// The digest is seeded with [`TREE_DOMAIN`] and the policy's tree kind. +/// +/// `root` must be canonical (callers check it with `real_directory`), since +/// alias containment compares canonical targets against it. +/// +/// Rejects non-UTF-8 names, absolute, dangling or root-escaping aliases, +/// special files and exhausted policy limits. +fn vendor_tree_digest( + root: &Path, + policy: TreePolicy, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + let mut pending = vec![root.to_owned()]; + let mut facts = BTreeMap::new(); + let mut regular_bytes = 0_u64; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory).map_err(measurement_error)? { + check()?; + let path = entry.map_err(measurement_error)?.path(); + let relative = path + .strip_prefix(root) + .map_err(measurement_error)? + .to_str() + .ok_or_else(|| recipe_error("Apple toolchain path is not UTF-8"))? + .to_owned(); + if facts.len() >= policy.entries { + return Err(recipe_error("Apple toolchain tree exceeds entry limit")); + } + let metadata = fs::symlink_metadata(&path).map_err(measurement_error)?; + let mode = metadata.permissions().mode() & 0o7777; + let fact = if metadata.is_dir() { + pending.push(path); + format!("directory:{mode}") + } else if metadata.is_file() { + let (sha256, size) = hash_vendor_file(&path, policy.file_bytes, check)?; + regular_bytes += size; + if regular_bytes > policy.tree_bytes { + return Err(recipe_error("Apple toolchain tree exceeds byte limit")); + } + format!("file:{mode}:{size}:{sha256}") + } else if metadata.file_type().is_symlink() { + let target = fs::read_link(&path).map_err(measurement_error)?; + let target = target + .to_str() + .filter(|text| !target.is_absolute() && !text.is_empty()) + .ok_or_else(|| recipe_error("Apple toolchain alias is not relative UTF-8"))? + .to_owned(); + if !fs::canonicalize(&path) + .map_err(measurement_error)? + .starts_with(root) + { + return Err(recipe_error("Apple toolchain alias escapes its tree")); + } + format!("alias:{target}") + } else { + return Err(recipe_error("Apple toolchain tree contains a special file")); + }; + facts.insert(relative, fact); + } + } + let mut digest = Sha256::new(); + digest.update(TREE_DOMAIN); + digest.update(policy.kind.as_bytes()); + digest.update([0]); + for (relative, fact) in &facts { + digest.update(relative.as_bytes()); + digest.update([0]); + digest.update(fact.as_bytes()); + digest.update([0]); + } + Ok(VendorTreeReceipt { + tree_sha256: hex::encode(digest.finalize()), + regular_bytes, + entries: facts.len(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs b/crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs new file mode 100644 index 000000000..eb145ca5e --- /dev/null +++ b/crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs @@ -0,0 +1,808 @@ +use std::ffi::CString; +use std::fs; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::symlink; +use std::time::Duration; + +use super::*; + +/// A fictional developer directory with one default toolchain. +struct Toolchain { + _directory: tempfile::TempDir, + developer: PathBuf, + usr: PathBuf, +} + +impl Toolchain { + fn new() -> Self { + let directory = tempfile::tempdir().expect("should create toolchain fixture"); + let developer = fs::canonicalize(directory.path()) + .expect("should canonicalize fixture") + .join("developer"); + let usr = developer.join("Toolchains/XcodeDefault.xctoolchain/usr"); + for relative in ["bin", "lib/clang/17/include", "lib/clang/17/lib/darwin"] { + fs::create_dir_all(usr.join(relative)).expect("should create toolchain directory"); + } + for (relative, bytes) in [ + ("bin/clang", "fictional clang"), + ("bin/ar", "fictional ar"), + ("bin/ld", "fictional ld"), + ("lib/libLTO.dylib", "fictional libLTO"), + ("lib/clang/17/include/stddef.h", "fictional header"), + ( + "lib/clang/17/lib/darwin/libclang_rt.osx.a", + "fictional runtime", + ), + ] { + fs::write(usr.join(relative), bytes).expect("should write toolchain file"); + } + symlink("clang", usr.join("bin/clang++")).expect("should alias clang++"); + symlink("17", usr.join("lib/clang/17.0.0")).expect("should alias resource version"); + Self { + _directory: directory, + developer, + usr, + } + } + + fn measure(&self) -> Result> { + MeasuredToolchain::measure( + &self.usr.join("bin/clang"), + &self.developer, + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + } + + fn receipt(&self) -> ToolchainReceipt { + self.measure() + .expect("should measure fictional toolchain") + .receipt() + .clone() + } + + fn error(&self) -> String { + format!( + "{:?}", + self.measure() + .expect_err("should reject the fictional toolchain") + ) + } +} + +/// A named fixture edit of one toolchain directory. +type Mutation = fn(&Path); + +fn make_fifo(path: &Path) { + let fifo = CString::new(path.to_str().expect("should have UTF-8 path")) + .expect("should encode FIFO path"); + // SAFETY: the CString remains alive and points to a NUL-terminated path. + assert_eq!( + unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, + 0, + "should create special file" + ); +} + +#[test] +fn library_tree_detects_an_added_sibling_library() { + let toolchain = Toolchain::new(); + let measured = toolchain + .measure() + .expect("should measure selected toolchain"); + fs::write( + toolchain.usr.join("lib/additional.dylib"), + b"fictional library", + ) + .expect("should add sibling library"); + assert!( + measured.verify().is_err(), + "should reject a library added after measurement" + ); +} + +#[test] +fn library_tree_records_all_subdirectories_and_structured_identity() { + let toolchain = Toolchain::new(); + fs::create_dir_all(toolchain.usr.join("lib/swift/nested")) + .expect("should create library subdirectories"); + fs::write(toolchain.usr.join("lib/swift/nested/extra.dylib"), b"extra") + .expect("should write nested library"); + symlink("nested", toolchain.usr.join("lib/swift/current")) + .expect("should alias nested library directory"); + let measured = toolchain + .measure() + .expect("should measure library inventory"); + let receipt = measured.receipt(); + assert_eq!( + receipt.library_tree.entries, 13, + "should count every library entry" + ); + assert_eq!( + receipt.library_tree.regular_bytes, + u64::try_from( + b"fictional libLTO".len() + + b"fictional header".len() + + b"fictional runtime".len() + + b"extra".len() + ) + .expect("should fit fixture size"), + "should include resources and LTO without following aliases" + ); + let direct = vendor_tree_digest(&toolchain.usr.join("lib"), LIBRARY_POLICY, &|| Ok(())) + .expect("should independently measure entire library directory"); + assert_eq!( + receipt.library_tree, direct, + "should preserve complete structured inventory" + ); + assert_eq!( + receipt.library_tree_sha256(), + direct.tree_sha256, + "should expose structured digest" + ); + assert_ne!( + receipt.library_tree_sha256(), + receipt.liblto_sha256(), + "should not expose a raw-file digest as tree identity" + ); + assert_eq!( + toolchain.receipt(), + *receipt, + "should retain identical unchanged receipt" + ); + measured + .verify() + .expect("should verify unchanged complete library tree"); +} + +#[test] +fn library_tree_detects_content_deletion_and_alias_retargeting() { + for mutation in [ + ( + "content", + (|lib: &Path| { + fs::write(lib.join("extra.dylib"), b"changed") + .expect("should edit sibling library"); + }) as Mutation, + ), + ("deletion", |lib: &Path| { + fs::remove_file(lib.join("current.dylib")).expect("should remove library alias"); + fs::remove_file(lib.join("extra.dylib")).expect("should delete sibling library"); + }), + ("alias target", |lib: &Path| { + fs::remove_file(lib.join("current.dylib")).expect("should remove alias"); + symlink("libLTO.dylib", lib.join("current.dylib")).expect("should retarget alias"); + }), + ] { + let toolchain = Toolchain::new(); + let lib = toolchain.usr.join("lib"); + fs::write(lib.join("extra.dylib"), b"extra").expect("should write sibling library"); + symlink("extra.dylib", lib.join("current.dylib")).expect("should add library alias"); + let measured = toolchain.measure().expect("should measure sibling library"); + mutation.1(&lib); + assert!( + measured.verify().is_err(), + "should reject changed {}", + mutation.0 + ); + assert_ne!( + toolchain.receipt().library_tree_sha256(), + measured.receipt().library_tree_sha256(), + "should bind changed {} in digest", + mutation.0 + ); + } +} + +#[test] +fn library_tree_rejects_unsafe_entries_and_oversized_files() { + let cases: [(&str, Mutation); 5] = [ + ("absolute alias", |lib| { + symlink("/usr/lib", lib.join("absolute")).expect("should add absolute alias"); + }), + ("escaping alias", |lib| { + symlink("../bin/clang", lib.join("escape")).expect("should add escaping alias"); + }), + ("dangling alias", |lib| { + symlink("missing", lib.join("dangling")).expect("should add dangling alias"); + }), + ("special file", |lib| make_fifo(&lib.join("fifo"))), + ("oversized file", |lib| { + File::create(lib.join("oversized.dylib")) + .expect("should create sparse library") + .set_len(LIBRARY_POLICY.file_bytes + 1) + .expect("should exceed library file bound"); + }), + ]; + for (label, mutate) in cases { + let toolchain = Toolchain::new(); + mutate(&toolchain.usr.join("lib")); + assert!( + toolchain.measure().is_err(), + "should reject library {label}" + ); + } +} + +#[test] +fn library_tree_requires_a_real_unaliased_directory_root() { + for alias in [false, true] { + let toolchain = Toolchain::new(); + let lib = toolchain.usr.join("lib"); + let measured = toolchain + .measure() + .expect("should measure real library directory"); + fs::rename(&lib, toolchain.usr.join("other-lib")).expect("should move library directory"); + if alias { + symlink("other-lib", &lib).expect("should alias library root"); + } else { + fs::write(&lib, b"not a directory").expect("should replace library root with file"); + } + assert!( + toolchain.measure().is_err(), + "should reject non-directory or aliased library root" + ); + assert!( + measured.verify().is_err(), + "should reject changed library root" + ); + } +} + +#[test] +fn library_tree_rejects_unreadable_entries_and_obeys_shared_budget() { + let toolchain = Toolchain::new(); + let lib = toolchain.usr.join("lib"); + let extra = lib.join("extra.dylib"); + fs::write(&extra, b"extra").expect("should write extra library"); + let measured = toolchain.measure().expect("should measure extra library"); + fs::set_permissions(&extra, fs::Permissions::from_mode(0o0)) + .expect("should remove library read permissions"); + assert!( + measured.verify().is_err(), + "should reject unreadable library" + ); + fs::set_permissions(&extra, fs::Permissions::from_mode(0o600)) + .expect("should restore library read permissions"); + for expired in [false, true] { + let cancellation = Cancellation::default(); + let deadline = if expired { + Instant::now() - Duration::from_secs(1) + } else { + cancellation.cancel(); + Instant::now() + Duration::from_secs(30) + }; + assert!( + vendor_tree_digest(&lib, LIBRARY_POLICY, &|| budget(deadline, &cancellation)).is_err(), + "should reject exhausted library tree attempt budget" + ); + } +} + +#[test] +fn libtapi_absence_requires_a_real_unaliased_parent() { + let toolchain = Toolchain::new(); + let missing = toolchain.usr.join("missing"); + let alias = toolchain.usr.join("aliased-lib"); + symlink("lib", &alias).expect("should alias library directory"); + let dangling = toolchain.usr.join("dangling-lib"); + symlink("missing", &dangling).expect("should create dangling parent alias"); + for parent in [&missing, &alias, &dangling] { + assert!( + optional_libtapi_digest(&parent.join("libtapi.dylib"), &|| Ok(())).is_err(), + "should not record absence below a missing or aliased parent" + ); + } +} + +#[test] +fn libtapi_receipt_records_digest_or_explicit_null_and_detects_presence_changes() { + let toolchain = Toolchain::new(); + let library = toolchain.usr.join(LIBTAPI); + let absent = toolchain.measure().expect("should measure without libtapi"); + let serialized = serde_json::to_value(absent.receipt()).expect("should serialize receipt"); + assert_eq!( + serialized.get("libtapi_sha256"), + Some(&serde_json::Value::Null), + "should explicitly record genuine absence" + ); + assert_eq!( + absent.receipt().libtapi_sha256(), + None, + "should expose absence" + ); + fs::write(&library, b"fictional libtapi").expect("should add libtapi"); + assert!(absent.verify().is_err(), "should reject added libtapi"); + let present = toolchain.measure().expect("should measure present libtapi"); + let digest = hex::encode(Sha256::digest(b"fictional libtapi")); + assert_eq!( + present.receipt().libtapi_sha256(), + Some(digest.as_str()), + "should hash exact selected libtapi bytes" + ); + let serialized = serde_json::to_value(present.receipt()).expect("should serialize receipt"); + assert_eq!( + serialized["libtapi_sha256"], digest, + "should serialize measured digest" + ); + present.verify().expect("should verify unchanged libtapi"); + fs::remove_file(&library).expect("should remove libtapi"); + assert!(present.verify().is_err(), "should reject removed libtapi"); + assert_eq!( + toolchain.receipt(), + *absent.receipt(), + "should remeasure genuine absence identically" + ); +} + +#[test] +fn libtapi_rejects_aliases_directories_special_files_and_oversize_files() { + let cases: [(&str, Mutation); 5] = [ + ("alias", |path| { + symlink("libLTO.dylib", path).expect("should alias libtapi"); + }), + ("dangling alias", |path| { + symlink("missing.dylib", path).expect("should add dangling alias"); + }), + ("directory", |path| { + fs::create_dir(path).expect("should create directory"); + }), + ("special file", make_fifo), + ("oversize file", |path| { + File::create(path) + .expect("should create sparse file") + .set_len(128 * 1024 * 1024 + 1) + .expect("should exceed fixed TAPI limit"); + }), + ]; + for (label, mutate) in cases { + let toolchain = Toolchain::new(); + mutate(&toolchain.usr.join(LIBTAPI)); + assert!( + toolchain.measure().is_err(), + "should reject libtapi {label}" + ); + } +} + +#[test] +fn libtapi_rejects_unreadable_files_and_honors_the_attempt_budget() { + let toolchain = Toolchain::new(); + let library = toolchain.usr.join(LIBTAPI); + fs::write(&library, b"fictional libtapi").expect("should write libtapi"); + let measured = toolchain.measure().expect("should measure libtapi"); + fs::set_permissions(&library, fs::Permissions::from_mode(0o0)) + .expect("should remove read permissions"); + assert!( + toolchain.measure().is_err(), + "should reject unreadable libtapi" + ); + fs::set_permissions(&library, fs::Permissions::from_mode(0o600)) + .expect("should restore permissions"); + measured.cancellation.cancel(); + assert!( + measured.verify().is_err(), + "should stop cancelled libtapi remeasurement" + ); + for present in [false, true] { + if !present { + fs::remove_file(&library).expect("should remove libtapi"); + } else { + fs::write(&library, b"fictional libtapi").expect("should restore libtapi"); + } + assert!( + optional_libtapi_digest(&library, &|| budget( + Instant::now() - Duration::from_secs(1), + &Cancellation::default() + )) + .is_err(), + "should reject an expired budget even when presence is {present}" + ); + } +} + +#[test] +fn libtapi_change_is_detected_during_remeasurement() { + let toolchain = Toolchain::new(); + let library = toolchain.usr.join("lib/libtapi.dylib"); + fs::write(&library, b"fictional libtapi").expect("should write libtapi"); + let measured = toolchain.measure().expect("should measure toolchain"); + fs::write(&library, b"changed libtapi").expect("should change libtapi"); + assert!(measured.verify().is_err(), "should reject changed libtapi"); +} + +#[test] +fn receipt_records_single_resource_version_aliases_and_measured_trees() { + let toolchain = Toolchain::new(); + let receipt = toolchain.receipt(); + assert_eq!( + receipt.clang_resource_version, "17", + "should select the only real version directory" + ); + assert_eq!( + receipt.clang_resource_aliases, + vec!["17.0.0".to_owned()], + "should record the version alias" + ); + assert_eq!( + receipt.liblto_sha256(), + hex::encode(Sha256::digest(b"fictional libLTO")), + "should hash libLTO bytes" + ); + assert_eq!( + receipt.clang_resource_tree.entries, 5, + "should count every resource entry" + ); + assert_eq!( + receipt.helper_tree.entries, 4, + "should count helper files and aliases" + ); + assert_eq!( + toolchain.receipt(), + receipt, + "should remeasure identically when nothing changed" + ); + toolchain + .measure() + .expect("should measure") + .verify() + .expect("should verify unchanged toolchain"); +} + +#[test] +fn resource_tree_digest_changes_when_any_resource_entry_changes() { + let toolchain = Toolchain::new(); + let measured = toolchain.measure().expect("should measure"); + let original = measured.receipt().clang_resource_tree_sha256().to_owned(); + fs::write( + toolchain.usr.join("lib/clang/17/include/stddef.h"), + b"edited header", + ) + .expect("should edit resource header"); + assert_ne!( + toolchain.receipt().clang_resource_tree_sha256(), + original, + "should bind resource header bytes" + ); + assert!( + measured.verify().is_err(), + "should reject an edited resource directory" + ); + fs::write(toolchain.usr.join("lib/clang/17/include/new.h"), b"added") + .expect("should add resource header"); + assert!( + measured.verify().is_err(), + "should reject an added resource entry" + ); +} + +#[test] +fn resource_selection_fails_closed_on_ambiguous_or_foreign_entries() { + let cases: [(&str, Mutation); 5] = [ + ("second version", |resources| { + fs::create_dir(resources.join("18")).expect("should add second version"); + }), + ("foreign alias", |resources| { + fs::create_dir(resources.join("18")).expect("should add directory"); + fs::remove_dir_all(resources.join("17")).expect("should remove version"); + symlink("../..", resources.join("17")).expect("should add foreign alias"); + }), + ("alias to other", |resources| { + fs::remove_file(resources.join("17.0.0")).expect("should remove alias"); + symlink("17/include", resources.join("17.0.0")).expect("should add nested alias"); + }), + ("regular file", |resources| { + fs::write(resources.join("19"), b"file").expect("should add file"); + }), + ("non version name", |resources| { + fs::create_dir(resources.join("include")).expect("should add directory"); + }), + ]; + for (label, mutate) in cases { + let toolchain = Toolchain::new(); + mutate(&toolchain.usr.join("lib/clang")); + let error = toolchain.error(); + assert!( + error.contains("clang resource"), + "should reject {label} in resource selection: {error}" + ); + } + let toolchain = Toolchain::new(); + fs::remove_dir_all(toolchain.usr.join("lib/clang/17")).expect("should remove version"); + fs::remove_file(toolchain.usr.join("lib/clang/17.0.0")).expect("should remove alias"); + assert!( + toolchain.error().contains("exactly one version directory"), + "should reject a missing resource version" + ); +} + +#[test] +fn liblto_must_be_present_regular_and_unchanged() { + let toolchain = Toolchain::new(); + let measured = toolchain.measure().expect("should measure"); + fs::write(toolchain.usr.join("lib/libLTO.dylib"), b"changed libLTO") + .expect("should change libLTO"); + assert!(measured.verify().is_err(), "should reject changed libLTO"); + fs::remove_file(toolchain.usr.join("lib/libLTO.dylib")).expect("should remove libLTO"); + assert!( + toolchain.measure().is_err(), + "should reject a missing libLTO" + ); + fs::write(toolchain.usr.join("lib/real-lto.dylib"), b"aliased libLTO") + .expect("should write alias target"); + symlink("real-lto.dylib", toolchain.usr.join("lib/libLTO.dylib")).expect("should alias libLTO"); + assert!( + toolchain.measure().is_err(), + "should reject an aliased libLTO" + ); + fs::remove_file(toolchain.usr.join("lib/libLTO.dylib")).expect("should remove alias"); + fs::create_dir(toolchain.usr.join("lib/libLTO.dylib")).expect("should add directory"); + assert!( + toolchain.measure().is_err(), + "should reject a non-regular libLTO" + ); +} + +#[test] +fn helper_tree_records_alias_targets() { + let toolchain = Toolchain::new(); + let measured = toolchain.measure().expect("should measure"); + let original = measured.receipt().helper_tree_sha256().to_owned(); + fs::remove_file(toolchain.usr.join("bin/clang++")).expect("should remove alias"); + symlink("ld", toolchain.usr.join("bin/clang++")).expect("should retarget alias"); + assert_ne!( + toolchain.receipt().helper_tree_sha256(), + original, + "should bind alias targets" + ); + assert!( + measured.verify().is_err(), + "should reject a retargeted alias" + ); +} + +#[test] +fn helper_tree_measures_vendor_hardlinks_and_detects_edits_through_the_other_path() { + let toolchain = Toolchain::new(); + let unlinked = toolchain.receipt(); + fs::hard_link( + toolchain.usr.join("bin/ar"), + toolchain.usr.join("bin/ranlib"), + ) + .expect("should add in-tree vendor hardlink"); + fs::hard_link( + toolchain.usr.join("bin/ld"), + toolchain.usr.join("lib/ld-copy"), + ) + .expect("should add out-of-tree vendor hardlink"); + let measured = toolchain + .measure() + .expect("should measure hardlinked vendor helpers in place"); + assert_eq!( + measured.receipt().helper_tree.entries, + 5, + "should count the in-tree hardlink as an entry" + ); + assert_ne!( + measured.receipt().helper_tree_sha256(), + unlinked.helper_tree_sha256(), + "should bind the added hardlinked entry" + ); + fs::write( + toolchain.usr.join("lib/ld-copy"), + b"edited through the other link", + ) + .expect("should edit shared content outside the tree"); + assert!( + measured.verify().is_err(), + "should reject helper content edited through an out-of-tree hardlink" + ); +} + +#[test] +fn trees_accept_nested_directories_and_internal_directory_aliases() { + let toolchain = Toolchain::new(); + let unnested = toolchain.receipt(); + fs::create_dir_all(toolchain.usr.join("bin/nested/deeper")).expect("should nest helpers"); + fs::write( + toolchain.usr.join("bin/nested/deeper/helper"), + b"fictional nested helper", + ) + .expect("should write nested helper"); + symlink( + "darwin", + toolchain.usr.join("lib/clang/17/lib/macho_embedded"), + ) + .expect("should alias a resource subdirectory"); + let measured = toolchain + .measure() + .expect("should accept nested directories and internal directory aliases"); + let receipt = measured.receipt(); + assert_eq!( + receipt.helper_tree.entries, 7, + "should count nested helper directories and files" + ); + assert_eq!( + receipt.clang_resource_tree.entries, 6, + "should count the internal directory alias" + ); + assert!( + receipt.helper_tree_sha256() != unnested.helper_tree_sha256() + && receipt.clang_resource_tree_sha256() != unnested.clang_resource_tree_sha256(), + "should bind nested and aliased entries" + ); + fs::write(toolchain.usr.join("bin/nested/deeper/helper"), b"edited") + .expect("should edit nested helper"); + assert!( + measured.verify().is_err(), + "should reject an edited nested helper" + ); +} + +#[test] +fn resource_tree_rejects_special_files() { + let toolchain = Toolchain::new(); + make_fifo(&toolchain.usr.join("lib/clang/17/include/fifo")); + assert!( + toolchain.error().contains("special file"), + "should reject a special file inside the resource tree" + ); +} + +#[test] +fn trees_reject_non_utf8_names_where_the_filesystem_allows_them() { + let toolchain = Toolchain::new(); + let name = std::ffi::OsStr::from_bytes(b"helper-\xff"); + // APFS refuses invalid UTF-8 names, so on macOS the hazard cannot exist + // and creation fails; filesystems that accept the name must be rejected. + if fs::write(toolchain.usr.join("bin").join(name), b"fictional helper").is_err() { + return; + } + assert!( + toolchain.error().contains("not UTF-8"), + "should reject a non-UTF-8 helper name" + ); +} + +#[test] +fn tree_kinds_are_domain_separated() { + let toolchain = Toolchain::new(); + let bin = toolchain.usr.join("bin"); + let unbounded = || Ok(()); + let as_helpers = + vendor_tree_digest(&bin, HELPER_POLICY, &unbounded).expect("should digest as helpers"); + let as_resources = + vendor_tree_digest(&bin, RESOURCE_POLICY, &unbounded).expect("should digest as resources"); + assert_ne!( + as_helpers.tree_sha256, as_resources.tree_sha256, + "should separate identical contents by tree kind" + ); + let as_libraries = + vendor_tree_digest(&bin, LIBRARY_POLICY, &unbounded).expect("should digest as libraries"); + assert!( + as_libraries.tree_sha256 != as_helpers.tree_sha256 + && as_libraries.tree_sha256 != as_resources.tree_sha256, + "should separate library identity from both other tree kinds" + ); +} + +#[test] +fn clang_path_must_be_normal_and_inside_usr_bin() { + let toolchain = Toolchain::new(); + fs::create_dir_all(toolchain.usr.join("libexec")).expect("should create other directory"); + fs::write(toolchain.usr.join("libexec/clang"), b"fictional clang") + .expect("should write misplaced clang"); + fs::create_dir_all(toolchain.usr.join("bin/bin")).expect("should create nested bin"); + fs::write(toolchain.usr.join("bin/bin/clang"), b"fictional clang") + .expect("should write nested clang"); + let cases = [ + ("parent component", toolchain.usr.join("bin/../bin/clang")), + ("relative path", PathBuf::from("usr/bin/clang")), + ("not in bin", toolchain.usr.join("libexec/clang")), + ("bin not in usr", toolchain.usr.join("bin/bin/clang")), + ]; + for (label, clang) in cases { + let error = format!( + "{:?}", + MeasuredToolchain::measure( + &clang, + &toolchain.developer, + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect_err("should reject the clang path") + ); + assert!( + error.contains("Apple clang"), + "should reject {label} before measuring: {error}" + ); + } +} + +#[test] +fn helper_tree_rejects_unsafe_aliases_and_special_files() { + let cases: [(&str, Mutation); 4] = [ + ("absolute alias", |bin| { + symlink("/usr/bin/true", bin.join("absolute")).expect("should add alias"); + }), + ("escaping alias", |bin| { + symlink("../lib/libLTO.dylib", bin.join("escape")).expect("should add alias"); + }), + ("dangling alias", |bin| { + symlink("missing", bin.join("dangling")).expect("should add alias"); + }), + ("special file", |bin| make_fifo(&bin.join("fifo"))), + ]; + for (label, mutate) in cases { + let toolchain = Toolchain::new(); + mutate(&toolchain.usr.join("bin")); + assert!(toolchain.measure().is_err(), "should reject {label}"); + } +} + +#[test] +fn vendor_tree_enforces_entry_file_and_tree_bounds() { + let toolchain = Toolchain::new(); + let bin = toolchain.usr.join("bin"); + let unbounded = || Ok(()); + for base in [HELPER_POLICY, LIBRARY_POLICY] { + for policy in [ + TreePolicy { entries: 3, ..base }, + TreePolicy { + file_bytes: 4, + ..base + }, + TreePolicy { + tree_bytes: 20, + ..base + }, + ] { + assert!( + vendor_tree_digest(&bin, policy, &unbounded).is_err(), + "should reject exhausted {policy:?}" + ); + } + } + assert!( + vendor_tree_digest(&bin, HELPER_POLICY, &unbounded).is_ok(), + "should accept the fixture within real limits" + ); +} + +#[test] +fn measurement_stops_when_cancelled_expired_or_outside_developer_dir() { + let toolchain = Toolchain::new(); + let cancellation = Cancellation::default(); + cancellation.cancel(); + let clang = toolchain.usr.join("bin/clang"); + assert!( + MeasuredToolchain::measure( + &clang, + &toolchain.developer, + Instant::now() + Duration::from_secs(30), + &cancellation + ) + .is_err(), + "should stop a cancelled measurement" + ); + assert!( + MeasuredToolchain::measure( + &clang, + &toolchain.developer, + Instant::now() - Duration::from_secs(1), + &Cancellation::default() + ) + .is_err(), + "should stop an expired measurement" + ); + assert!( + MeasuredToolchain::measure( + &clang, + &toolchain.usr.join("bin"), + Instant::now() + Duration::from_secs(30), + &Cancellation::default() + ) + .is_err(), + "should reject a toolchain outside the developer directory" + ); +} diff --git a/crates/trusted-server-release/src/recipe/artifact.rs b/crates/trusted-server-release/src/recipe/artifact.rs new file mode 100644 index 000000000..12e3c01b0 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/artifact.rs @@ -0,0 +1,292 @@ +//! Host-side copy of the built executable into the fresh output directory. +//! +//! The executable stays inside the writable build root, which a confined +//! child could still hold open or relink, so it is never published in place. +//! It is read once through a held descriptor and written into a new file +//! created exclusively and without following links, hashing exactly the bytes +//! written. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Component, Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::recipe_error; +use crate::error::ReleaseError; + +/// Largest executable the recipe copies. +pub(crate) const EXECUTABLE_LIMIT: u64 = 512 * 1024 * 1024; +/// Mode of the published asset, independent of the process umask. +const ASSET_MODE: u32 = 0o755; +/// Longest accepted asset file name. +const NAME_LIMIT: usize = 160; + +/// The copied asset and the digest of exactly the bytes written. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CopiedArtifact { + name: String, + size: u64, + sha256: String, + path: PathBuf, +} + +impl CopiedArtifact { + /// Asset file name inside the output directory. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Written size in bytes. + pub(crate) fn size(&self) -> u64 { + self.size + } + + /// Lowercase hex SHA-256 of the written bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Host path of the written file. + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +fn artifact_error(detail: impl std::fmt::Display) -> Report { + recipe_error(format!("artifact copy: {detail}")) +} + +/// Requires a flat file name of letters, digits, `.`, `-` and `_` that does +/// not start with a dot. +fn check_name(name: &str) -> Result<(), Report> { + let first_ok = name + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()); + let body_ok = name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')); + if name.len() > NAME_LIMIT || !first_ok || !body_ok || name.contains("..") { + return Err(artifact_error(format!( + "output name {name:?} is not a safe flat name" + ))); + } + Ok(()) +} + +/// Requires an existing real directory that no symlink aliases. +fn check_output_directory(output_dir: &Path) -> Result<(), Report> { + let metadata = fs::symlink_metadata(output_dir).map_err(|error| { + artifact_error(format!("output directory cannot be inspected: {error}")) + })?; + let resolved = fs::canonicalize(output_dir) + .map_err(|error| artifact_error(format!("output directory cannot be resolved: {error}")))?; + if !metadata.is_dir() || resolved.as_os_str() != output_dir.as_os_str() { + return Err(artifact_error( + "output directory is not a real directory reached without symlinks", + )); + } + Ok(()) +} + +/// Requires every directory from the writable `root` down to the source's +/// parent to be a real directory, so no parent symlink can redirect the read. +/// +/// Dot segments and sources outside `root` are rejected. The path is +/// re-resolved at the end and must equal itself. The leaf is opened with +/// `O_NOFOLLOW` separately. A confined child no longer runs when this is +/// called, so only a concurrent host process could race the walk. +fn check_source_path(root: &Path, source: &Path) -> Result<(), Report> { + let relative = source + .strip_prefix(root) + .map_err(|_| artifact_error("source is outside the writable root"))?; + let normal = source.components().collect::().as_os_str() == source.as_os_str() + && relative + .components() + .all(|component| matches!(component, Component::Normal(_))); + let parent = source + .parent() + .filter(|_| normal && source.is_absolute() && !relative.as_os_str().is_empty()) + .ok_or_else(|| artifact_error("source is not an absolute normal path below the root"))?; + let mut current = root.to_path_buf(); + let mut directories = vec![root.to_path_buf()]; + directories.extend( + parent + .strip_prefix(root) + .map_err(|_| artifact_error("source parent is outside the writable root"))? + .components() + .map(|component| { + current.push(component); + current.clone() + }), + ); + for directory in &directories { + let metadata = fs::symlink_metadata(directory).map_err(|error| { + artifact_error(format!("source directory cannot be inspected: {error}")) + })?; + if !metadata.is_dir() { + return Err(artifact_error(format!( + "source directory {} is a symlink or not a directory", + directory.display() + ))); + } + } + let resolved = fs::canonicalize(parent) + .map_err(|error| artifact_error(format!("source directory cannot be resolved: {error}")))?; + if resolved != parent || !resolved.starts_with(root) { + return Err(artifact_error( + "source directory resolves outside the writable root", + )); + } + Ok(()) +} + +/// Opens the source without following a leaf symlink and checks its facts. +fn open_source(source: &Path, limit: u64) -> Result<(File, fs::Metadata), Report> { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(source) + .map_err(|error| artifact_error(format!("source cannot be opened: {error}")))?; + let metadata = file + .metadata() + .map_err(|error| artifact_error(format!("source cannot be inspected: {error}")))?; + if !metadata.is_file() { + return Err(artifact_error("source is not a regular file")); + } + // Cargo hard-links the executable into `deps/`; a third link is foreign. + if !(1..=2).contains(&metadata.nlink()) { + return Err(artifact_error(format!( + "source has {} hard links; at most the Cargo deps link is expected", + metadata.nlink() + ))); + } + if metadata.len() > limit { + return Err(artifact_error(format!( + "source exceeds the {limit} byte limit" + ))); + } + Ok((file, metadata)) +} + +/// Streams `source` into `destination`, returning the byte count and digest. +fn stream( + source: &File, + before: &fs::Metadata, + destination: &mut File, +) -> Result<(u64, String), Report> { + let mut input = source; + let mut digest = Sha256::new(); + let mut size = 0_u64; + let mut buffer = vec![0_u8; 64 * 1024]; + loop { + let count = input + .read(&mut buffer) + .map_err(|error| artifact_error(format!("source read failed: {error}")))?; + if count == 0 { + break; + } + size += count as u64; + if size > before.len() { + return Err(artifact_error("source grows while it is copied")); + } + digest.update(&buffer[..count]); + destination + .write_all(&buffer[..count]) + .map_err(|error| artifact_error(format!("output write failed: {error}")))?; + } + let after = source + .metadata() + .map_err(|error| artifact_error(format!("source cannot be re-inspected: {error}")))?; + let stable = ( + before.dev(), + before.ino(), + before.len(), + before.mtime(), + before.mtime_nsec(), + ) == ( + after.dev(), + after.ino(), + after.len(), + after.mtime(), + after.mtime_nsec(), + ); + if size != before.len() || !stable { + return Err(artifact_error("source changes while it is copied")); + } + Ok((size, hex::encode(digest.finalize()))) +} + +/// Copies the executable at `source`, below the writable `source_root`, to +/// `output_dir/name`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for an unsafe name, a source path outside +/// `source_root`, with dot segments or below a symlinked directory, an aliased output +/// directory, a source that is a symlink, not a regular file, has more than +/// two hard links, exceeds `limit` or changes while copied, or an output that +/// already exists (including as a symlink). A failed copy removes the file it +/// created. +pub(crate) fn copy_artifact( + source: &Path, + source_root: &Path, + output_dir: &Path, + name: &str, + limit: u64, +) -> Result> { + check_name(name)?; + check_output_directory(output_dir)?; + check_source_path(source_root, source)?; + let (input, before) = open_source(source, limit)?; + let path = output_dir.join(name); + let mut destination = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path) + .map_err(|error| { + artifact_error(format!( + "output {name} cannot be created exclusively: {error}" + )) + })?; + let written = stream(&input, &before, &mut destination) + .and_then(|(size, sha256)| finish(&destination, &path, size).map(|()| (size, sha256))); + match written { + Ok((size, sha256)) => Ok(CopiedArtifact { + name: name.to_owned(), + size, + sha256, + path, + }), + Err(error) => { + // The file was created exclusively above, so it is ours to remove. + drop(destination); + let _ = fs::remove_file(&path); + Err(error) + } + } +} + +/// Sets the final mode, syncs and verifies the written file. +fn finish(destination: &File, path: &Path, size: u64) -> Result<(), Report> { + destination + .set_permissions(fs::Permissions::from_mode(ASSET_MODE)) + .and_then(|()| destination.sync_all()) + .map_err(|error| artifact_error(format!("output cannot be finalized: {error}")))?; + let metadata = fs::symlink_metadata(path) + .map_err(|error| artifact_error(format!("output cannot be re-inspected: {error}")))?; + if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() != size { + return Err(artifact_error( + "output is not one regular file of the written size", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/artifact/tests.rs b/crates/trusted-server-release/src/recipe/artifact/tests.rs new file mode 100644 index 000000000..d48377030 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/artifact/tests.rs @@ -0,0 +1,297 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; + +use sha2::Sha256; + +use super::*; + +const LIMIT: u64 = 1024; + +struct Layout { + _directory: tempfile::TempDir, + target: PathBuf, + output: PathBuf, +} + +fn layout() -> Layout { + let directory = tempfile::tempdir().expect("should create layout"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize layout"); + let target = base.join("target/release"); + let output = base.join("output"); + fs::create_dir_all(&target).expect("should create target release directory"); + fs::create_dir(&output).expect("should create output directory"); + fs::write(target.join("example-bin"), b"fictional executable").expect("should write source"); + Layout { + _directory: directory, + target, + output, + } +} + +impl Layout { + /// The writable root that holds `release/`. + fn root(&self) -> PathBuf { + self.target + .parent() + .expect("should have a writable root") + .to_path_buf() + } +} + +fn copy(layout: &Layout, name: &str) -> Result> { + copy_artifact( + &layout.target.join("example-bin"), + &layout.root(), + &layout.output, + name, + LIMIT, + ) +} + +fn rejection(layout: &Layout, name: &str) -> String { + match copy(layout, name) { + Ok(_) => panic!("should reject the copy"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn copy_records_name_size_and_sha256_of_the_written_bytes() { + let layout = layout(); + let copied = copy(&layout, "ts-example-asset").expect("should copy the executable"); + assert_eq!( + copied.name(), + "ts-example-asset", + "should keep the asset name" + ); + assert_eq!(copied.size(), 20, "should record the byte count"); + assert_eq!( + copied.sha256(), + hex::encode(Sha256::digest(b"fictional executable")), + "should hash the source bytes" + ); + let written = layout.output.join("ts-example-asset"); + assert_eq!( + fs::read(&written).expect("should read the output"), + b"fictional executable", + "should write identical bytes" + ); + let metadata = fs::symlink_metadata(&written).expect("should stat the output"); + assert!( + metadata.is_file() && metadata.nlink() == 1, + "should write one regular file" + ); + assert_eq!( + metadata.permissions().mode() & 0o7777, + 0o755, + "should set the mode independent of the umask" + ); + assert_eq!(copied.path(), written, "should report the output path"); +} + +#[test] +fn cargo_deps_hardlink_is_accepted_but_more_links_are_not() { + let layout = layout(); + fs::create_dir(layout.target.join("deps")).expect("should create deps"); + fs::hard_link( + layout.target.join("example-bin"), + layout.target.join("deps/example-bin-1234"), + ) + .expect("should link like Cargo does"); + copy(&layout, "ts-example-asset").expect("should accept the two-link Cargo layout"); + fs::hard_link( + layout.target.join("example-bin"), + layout.target.join("deps/extra"), + ) + .expect("should add a third link"); + assert!( + rejection(&layout, "ts-example-other").contains("link"), + "should reject a source with three links" + ); + assert!( + !layout.output.join("ts-example-other").exists(), + "should leave no output after a rejection" + ); +} + +#[test] +fn symlink_directory_and_oversize_sources_are_rejected_without_output() { + let layout = layout(); + fs::remove_file(layout.target.join("example-bin")).expect("should remove source"); + fs::write(layout.target.join("real"), b"x").expect("should write real file"); + symlink("real", layout.target.join("example-bin")).expect("should create source symlink"); + assert!( + rejection(&layout, "ts-example-asset").contains("source"), + "should reject a symlink source" + ); + fs::remove_file(layout.target.join("example-bin")).expect("should remove symlink"); + fs::create_dir(layout.target.join("example-bin")).expect("should create directory source"); + assert!( + rejection(&layout, "ts-example-asset").contains("source"), + "should reject a directory source" + ); + fs::remove_dir(layout.target.join("example-bin")).expect("should remove directory"); + fs::write(layout.target.join("example-bin"), vec![0_u8; 1025]).expect("should write oversize"); + assert!( + rejection(&layout, "ts-example-asset").contains("limit"), + "should reject an oversize source" + ); + assert_eq!( + fs::read_dir(&layout.output) + .expect("should list output") + .count(), + 0, + "should leave the output directory empty after every rejection" + ); +} + +#[test] +fn existing_or_aliased_output_is_never_overwritten() { + let layout = layout(); + fs::write(layout.output.join("ts-example-asset"), b"previous").expect("should write existing"); + assert!( + rejection(&layout, "ts-example-asset").contains("output"), + "should reject an existing file" + ); + assert_eq!( + fs::read(layout.output.join("ts-example-asset")).expect("should read existing"), + b"previous", + "should leave the existing file untouched" + ); + let victim = layout.target.join("victim"); + fs::write(&victim, b"victim").expect("should write victim"); + symlink(&victim, layout.output.join("ts-example-link")).expect("should plant symlink"); + assert!( + rejection(&layout, "ts-example-link").contains("output"), + "should reject a planted symlink" + ); + assert_eq!( + fs::read(&victim).expect("should read victim"), + b"victim", + "should not write through the link" + ); + symlink("missing", layout.output.join("ts-example-dangling")) + .expect("should plant dangling link"); + assert!( + rejection(&layout, "ts-example-dangling").contains("output"), + "should reject a dangling link" + ); + fs::create_dir(layout.output.join("ts-example-dir")).expect("should plant directory"); + assert!( + rejection(&layout, "ts-example-dir").contains("output"), + "should reject a directory" + ); +} + +#[test] +fn unsafe_names_and_aliased_output_directories_are_rejected() { + let layout = layout(); + for name in [ + "", + ".", + "..", + "a/b", + "../escape", + "/abs", + "has space", + "nul\0byte", + ".hidden", + ] { + assert!( + copy(&layout, name).is_err(), + "should reject asset file name {name:?}" + ); + } + let alias = layout + .output + .parent() + .expect("should have parent") + .join("alias"); + symlink(&layout.output, &alias).expect("should alias the output directory"); + let error = copy_artifact( + &layout.target.join("example-bin"), + &layout.root(), + &alias, + "ts-example-asset", + LIMIT, + ) + .expect_err("should reject a symlinked output directory"); + assert!( + format!("{error:?}").contains("output"), + "should name the output directory" + ); + assert!( + fs::read_dir(&layout.output) + .expect("should list output") + .next() + .is_none(), + "should write nothing through the alias" + ); +} + +#[test] +fn symlinked_source_directories_below_the_writable_root_are_rejected() { + let layout = layout(); + let root = layout.root(); + let real = root + .parent() + .expect("should have parent") + .join("real-release"); + fs::rename(&layout.target, &real).expect("should move the release directory"); + symlink(&real, &layout.target).expect("should alias the release directory"); + assert!( + rejection(&layout, "ts-example-asset").contains("source directory"), + "should reject a symlinked release directory" + ); + + let layout = self::layout(); + let root = layout.root(); + let triple = root.join("example-triple"); + let outside = root + .parent() + .expect("should have parent") + .join("outside-triple"); + fs::create_dir_all(outside.join("release")).expect("should create the outside tree"); + fs::write(outside.join("release/example-bin"), b"outside executable") + .expect("should write the outside executable"); + symlink(&outside, &triple).expect("should alias the triple directory"); + let error = copy_artifact( + &triple.join("release/example-bin"), + &root, + &layout.output, + "ts-example-asset", + LIMIT, + ) + .expect_err("should reject a symlinked triple directory"); + assert!( + format!("{error:?}").contains("source directory"), + "should name the aliased source directory" + ); + assert!( + fs::read_dir(&layout.output) + .expect("should list output") + .next() + .is_none(), + "should copy nothing through the alias" + ); +} + +#[test] +fn source_outside_the_writable_root_or_with_dot_segments_is_rejected() { + let layout = layout(); + let root = layout.root(); + for source in [ + layout.output.join("example-bin"), + root.join("release/../release/example-bin"), + root.join("release/./example-bin"), + PathBuf::from("relative/example-bin"), + ] { + let error = copy_artifact(&source, &root, &layout.output, "ts-example-asset", LIMIT) + .expect_err("should reject the source path"); + assert!( + format!("{error:?}").contains("source"), + "should reject {source:?} as a source path" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe/cargo_home.rs b/crates/trusted-server-release/src/recipe/cargo_home.rs new file mode 100644 index 000000000..8fcfb0857 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/cargo_home.rs @@ -0,0 +1,114 @@ +//! Reject fetched sources while admitting exact Cargo registry bookkeeping. + +use std::fs::{self, Metadata, OpenOptions}; +use std::io; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::Path; + +use error_stack::{Report, ResultExt as _}; +use sha2::{Digest as _, Sha256}; + +use super::recipe_error; +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; + +// Cargo 1.98's cargo-util creates exactly this tag before source replacement. +// The upstream URL is a required wire-format byte sequence, never a fetch target. +const CACHE_TAG: &[u8] = concat!( + "Signature: 8a477f597d28d172789f06886806bc55\n", + "# This file is a cache directory tag created by cargo.\n", + "# For information about cache directory tags see https://bford.info/cachedir/\n" // allow-domain: bford.info +) +.as_bytes(); + +fn stamp(metadata: &Metadata) -> (u64, u64, u64, u64, u32, i64, i64, i64, i64) { + ( + metadata.dev(), + metadata.ino(), + metadata.nlink(), + metadata.len(), + metadata.mode(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) +} + +/// Checks a private Cargo home without creating, deleting or repairing entries. +/// +/// # Errors +/// +/// Rejects fetched sources, unexpected bookkeeping, aliases, changed inputs, +/// unreadable paths and cancellation or an expired owning attempt. +pub(super) fn check( + cargo_home: &Path, + check_attempt: &dyn Fn() -> Result<(), Report>, +) -> Result<(), Report> { + check_attempt()?; + match fs::symlink_metadata(cargo_home.join("git")) { + Ok(_) => return Err(recipe_error("Cargo home holds a git cache")), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(recipe_error(format!( + "Cargo home git cache cannot be inspected: {error}" + ))); + } + } + let registry = cargo_home.join("registry"); + let before = match fs::symlink_metadata(®istry) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return check_attempt(), + Err(error) => { + return Err(recipe_error(format!( + "Cargo registry cannot be inspected: {error}" + ))); + } + }; + if !before.is_dir() { + return Err(recipe_error( + "Cargo registry bookkeeping must be a direct directory", + )); + } + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(®istry) + .map_err(recipe_error)?; + if stamp(&before) != stamp(&directory.metadata().map_err(recipe_error)?) { + return Err(recipe_error( + "Cargo registry directory changed before inspection", + )); + } + for entry in fs::read_dir(®istry).map_err(recipe_error)? { + check_attempt()?; + let entry = entry.map_err(recipe_error)?; + if entry.file_name() != "CACHEDIR.TAG" { + return Err(recipe_error( + "Cargo home holds a registry cache or unexpected registry entry", + )); + } + let tag = hash_bounded_with_check(&entry.path(), CACHE_TAG.len() as u64, check_attempt) + .change_context(ReleaseError::Recipe)?; + if tag.size != CACHE_TAG.len() as u64 + || tag.sha256 != hex::encode(Sha256::digest(CACHE_TAG)) + { + return Err(recipe_error( + "Cargo registry cache tag differs from exact Cargo bookkeeping", + )); + } + } + let after = fs::symlink_metadata(®istry).map_err(recipe_error)?; + if !after.is_dir() + || stamp(&before) != stamp(&after) + || stamp(&before) != stamp(&directory.metadata().map_err(recipe_error)?) + { + return Err(recipe_error( + "Cargo registry directory changed during inspection", + )); + } + check_attempt() +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/cargo_home/tests.rs b/crates/trusted-server-release/src/recipe/cargo_home/tests.rs new file mode 100644 index 000000000..5c23b8068 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/cargo_home/tests.rs @@ -0,0 +1,172 @@ +use std::cell::Cell; +use std::os::unix::fs::symlink; + +use super::*; + +fn home() -> tempfile::TempDir { + tempfile::tempdir().expect("should create a private Cargo home") +} + +fn registry(home: &Path) -> std::path::PathBuf { + let registry = home.join("registry"); + fs::create_dir(®istry).expect("should create registry bookkeeping"); + registry +} + +#[test] +fn only_absent_empty_or_exact_tag_bookkeeping_is_accepted() { + let home = home(); + check(home.path(), &|| Ok(())).expect("should accept no registry directory"); + let registry = registry(home.path()); + check(home.path(), &|| Ok(())).expect("should accept empty registry bookkeeping"); + assert_eq!( + CACHE_TAG.len(), + 177, + "should match the reviewed Cargo tag length" + ); + fs::write(registry.join("CACHEDIR.TAG"), CACHE_TAG) + .expect("should write exact Cargo bookkeeping"); + check(home.path(), &|| Ok(())).expect("should accept only the exact cache tag"); +} + +#[test] +fn fetched_sources_and_unexpected_entries_are_rejected_even_with_a_valid_tag() { + for name in ["index", "cache", "src", "unexpected"] { + let home = home(); + let registry = registry(home.path()); + fs::write(registry.join("CACHEDIR.TAG"), CACHE_TAG).expect("should write a valid tag"); + fs::create_dir(registry.join(name)).expect("should create a forbidden entry"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should reject any registry child beyond bookkeeping" + ); + } + let home = home(); + let registry = registry(home.path()); + fs::write(registry.join("unexpected"), b"example").expect("should create a forbidden file"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should reject unexpected regular files" + ); +} + +#[test] +fn changed_truncated_or_extended_tag_bytes_are_rejected() { + for bytes in [ + &b"example bookkeeping"[..], + &CACHE_TAG[..CACHE_TAG.len() - 1], + &[CACHE_TAG, b"extra"].concat(), + &vec![b'x'; CACHE_TAG.len()], + ] { + let home = home(); + let registry = registry(home.path()); + fs::write(registry.join("CACHEDIR.TAG"), bytes).expect("should write invalid tag bytes"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should reject every noncanonical tag" + ); + } +} + +#[test] +fn registry_aliases_and_nondirectories_are_rejected() { + let home = home(); + let outside = home.path().join("example"); + fs::create_dir(&outside).expect("should create a link target"); + symlink(&outside, home.path().join("registry")).expect("should create an alias"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should reject a registry alias" + ); + fs::remove_file(home.path().join("registry")).expect("should remove alias"); + fs::write(home.path().join("registry"), b"example").expect("should create a nondirectory"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should require a directory" + ); +} + +#[test] +fn tag_aliases_hardlinks_and_directories_are_rejected() { + let home = home(); + let registry = registry(home.path()); + let outside = home.path().join("example-tag"); + let tag = registry.join("CACHEDIR.TAG"); + fs::write(&outside, CACHE_TAG).expect("should write an external tag fixture"); + symlink(&outside, &tag).expect("should create tag alias"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should reject a tag alias" + ); + fs::remove_file(&tag).expect("should remove tag alias"); + fs::hard_link(&outside, &tag).expect("should create a shared tag inode"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should reject a hard-linked tag" + ); + fs::remove_file(&tag).expect("should remove hard link"); + fs::create_dir(&tag).expect("should create a tag directory"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should require a regular tag file" + ); +} + +#[test] +fn git_cache_stays_forbidden_including_empty_directories_and_dangling_aliases() { + let home = home(); + let git = home.path().join("git"); + fs::create_dir(&git).expect("should create an empty Git cache"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should reject even an empty Git cache" + ); + fs::remove_dir(&git).expect("should remove the directory"); + symlink("missing", &git).expect("should create a dangling Git alias"); + assert!( + check(home.path(), &|| Ok(())).is_err(), + "should reject a dangling Git alias" + ); +} + +#[test] +fn cancellation_is_not_skipped_for_absent_or_present_registry_state() { + let home = home(); + let cancelled = || Err(recipe_error("example cancelled attempt")); + assert!( + check(home.path(), &cancelled).is_err(), + "should check an absent registry attempt" + ); + registry(home.path()); + assert!( + check(home.path(), &cancelled).is_err(), + "should check a present registry attempt" + ); +} + +#[test] +fn replaced_registry_directory_is_rejected_even_with_identical_tag_bytes() { + let home = home(); + let registry = registry(home.path()); + fs::write(registry.join("CACHEDIR.TAG"), CACHE_TAG).expect("should write a tag"); + let calls = Cell::new(0); + let mutate = || { + calls.set(calls.get() + 1); + if calls.get() == 2 { + fs::rename(®istry, home.path().join("original")) + .expect("should replace the registry directory"); + fs::create_dir(®istry).expect("should create a replacement directory"); + fs::write(registry.join("CACHEDIR.TAG"), CACHE_TAG) + .expect("should copy identical bytes"); + } + Ok(()) + }; + assert!( + check(home.path(), &mutate).is_err(), + "should bind inspection to the original directory inode" + ); + assert!( + calls.get() >= 2, + "should exercise replacement during inspection" + ); +} diff --git a/crates/trusted-server-release/src/recipe/cargo_output.rs b/crates/trusted-server-release/src/recipe/cargo_output.rs new file mode 100644 index 000000000..55979a175 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/cargo_output.rs @@ -0,0 +1,228 @@ +//! Strict parser for Cargo's `--message-format=json` build stream. +//! +//! Cargo prints one JSON object per line. The recipe accepts the stream only +//! when every line is a strict, bounded JSON object, the stream ends in one +//! successful `build-finished`, and exactly one freshly compiled, debug-info +//! carrying binary artifact of the requested package and binary names was +//! produced, whose executable is a direct child of the target's release +//! directory. The parser never opens a file: it returns where Cargo says the +//! executable is, and the caller still re-reads and hashes those bytes. + +use std::path::{Component, Path, PathBuf}; + +use error_stack::Report; +use serde_json::Value; + +use super::fresh::normal_absolute; +use super::paths::ExecutionPaths; +use super::recipe_error; +use crate::documents::{Role, parse_json}; +use crate::error::ReleaseError; + +/// `profile.debuginfo` the release profile must report (limited debug info). +const REQUIRED_DEBUGINFO: u64 = 1; + +/// What the single binary artifact must be, in the execution namespace. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ExpectedBinary<'a> { + /// Cargo binary target name. + pub(crate) bin: &'a str, + /// Checkout path of the package manifest, as the child sees it. + pub(crate) manifest: &'a Path, + /// The `//release` directory, as the child sees it. + pub(crate) release_dir: &'a Path, +} + +/// The located executable in both namespaces. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocatedBinary { + /// Executable path as the confined child reported it. + pub(crate) execution: PathBuf, + /// The same file on the host, inside a writable root. + pub(crate) host: PathBuf, +} + +fn output_error(detail: impl std::fmt::Display) -> Report { + recipe_error(format!("cargo output: {detail}")) +} + +/// Splits `stdout` into strict JSON objects, one per newline-terminated line. +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() +} + +/// Requires the last message to be the only successful `build-finished`. +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(()) +} + +fn is_artifact(message: &Value) -> bool { + message["reason"].as_str() == Some("compiler-artifact") +} + +fn kinds(message: &Value) -> Option> { + message["target"]["kind"] + .as_array()? + .iter() + .map(Value::as_str) + .collect() +} + +fn is_requested(message: &Value, expected: &ExpectedBinary<'_>) -> bool { + kinds(message).as_deref() == Some(&["bin"][..]) + && message["target"]["name"].as_str() == Some(expected.bin) + && message["manifest_path"].as_str().map(Path::new) == Some(expected.manifest) +} + +/// Validates the one requested artifact and returns its executable path. +fn executable_of( + message: &Value, + expected: &ExpectedBinary<'_>, +) -> Result> { + if message["fresh"].as_bool() != Some(false) { + return Err(output_error( + "binary artifact is not reported fresh false; a cached artifact proves nothing", + )); + } + if message["profile"]["debuginfo"].as_u64() != Some(REQUIRED_DEBUGINFO) { + return Err(output_error(format!( + "binary artifact profile debuginfo is not {REQUIRED_DEBUGINFO}" + ))); + } + let text = message["executable"] + .as_str() + .ok_or_else(|| output_error("binary artifact has no executable path"))?; + let executable = PathBuf::from(text); + normal_absolute("cargo executable", &executable) + .map_err(|_| output_error("executable path is not an absolute normal path"))?; + let relative = executable + .strip_prefix(expected.release_dir) + .map_err(|_| output_error("executable is outside the release directory"))?; + let mut parts = relative.components(); + match (parts.next(), parts.next()) { + (Some(Component::Normal(_)), None) => Ok(executable), + _ => Err(output_error( + "executable is not a direct child of the release directory", + )), + } +} + +/// Parses the build stream and returns the container path of the executable. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for a non-strict or oversize line, a +/// stream not ending in exactly one successful `build-finished`, zero or more +/// than one artifact with `target.kind == ["bin"]`, the requested name and +/// manifest, an additional binary artifact, a fresh or unreported-freshness +/// artifact, `profile.debuginfo != 1`, or an executable that is missing, +/// relative, dotted, outside or nested below the release directory. +pub(crate) fn locate_binary( + stdout: &[u8], + expected: &ExpectedBinary<'_>, +) -> Result> { + let messages = successful_messages(stdout)?; + let artifacts: Vec<&Value> = messages + .iter() + .filter(|message| is_artifact(message)) + .collect(); + let matching: Vec<&&Value> = artifacts + .iter() + .filter(|message| is_requested(message, expected)) + .collect(); + let [requested] = matching.as_slice() else { + return Err(output_error(format!( + "expected exactly one binary artifact for {}, found {}", + expected.bin, + matching.len() + ))); + }; + if artifacts.iter().any(|message| { + !std::ptr::eq(*message, **requested) + && kinds(message).is_none_or(|kinds| kinds.contains(&"bin")) + }) { + return Err(output_error("unexpected additional binary artifact")); + } + executable_of(requested, expected) +} + +/// Parses bounded strict JSONL ending in one successful build completion. +/// +/// # Errors +/// +/// Returns an error for malformed, oversized, duplicate-key or incomplete output. +pub(crate) fn successful_messages(stdout: &[u8]) -> Result, Report> { + let messages = messages(stdout)?; + require_success(&messages)?; + Ok(messages) +} + +/// Parses the build stream and maps the executable into a writable host root. +/// +/// # Errors +/// +/// As [`locate_binary`], or [`ReleaseError::Recipe`] when the executable does +/// not map back through a writable root. +pub(crate) fn locate_host_binary( + stdout: &[u8], + expected: &ExpectedBinary<'_>, + paths: &dyn ExecutionPaths, +) -> Result> { + let execution = locate_binary(stdout, expected)?; + let host = paths + .host_path(&execution) + .map_err(|error| error.change_context(ReleaseError::Recipe)) + .map_err(|error| error.attach("executable is outside every writable root"))?; + Ok(LocatedBinary { execution, host }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/cargo_output/tests.rs b/crates/trusted-server-release/src/recipe/cargo_output/tests.rs new file mode 100644 index 000000000..99d63bc60 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/cargo_output/tests.rs @@ -0,0 +1,312 @@ +use std::path::Path; + +use serde_json::{Value, json}; + +use super::*; +use crate::recipe::paths::PrefixPaths; + +const MANIFEST: &str = "/ts/immutable/0/crates/example-adapter/Cargo.toml"; +const RELEASE: &str = "/ts/writable/1/wasm32-example/release"; + +/// A message edit applied before serialization. +type Edit = fn(&mut Value); + +fn expected() -> ExpectedBinary<'static> { + ExpectedBinary { + bin: "example-bin", + manifest: Path::new(MANIFEST), + release_dir: Path::new(RELEASE), + } +} + +fn artifact() -> Value { + json!({ + "reason": "compiler-artifact", + "package_id": "path+file:///example#0.1.0", + "manifest_path": MANIFEST, + "target": {"kind": ["bin"], "crate_types": ["bin"], "name": "example-bin"}, + "profile": {"opt_level": "3", "debuginfo": 1, "debug_assertions": false}, + "filenames": [format!("{RELEASE}/example-bin.wasm")], + "executable": format!("{RELEASE}/example-bin.wasm"), + "fresh": false + }) +} + +fn build_script() -> Value { + json!({ + "reason": "compiler-artifact", + "manifest_path": MANIFEST, + "target": {"kind": ["custom-build"], "crate_types": ["bin"], "name": "build-script-build"}, + "profile": {"debuginfo": 0}, + "executable": null, + "fresh": false + }) +} + +fn finished(success: &Value) -> Value { + json!({"reason": "build-finished", "success": success}) +} + +fn lines(messages: &[Value]) -> Vec { + let mut out = Vec::new(); + for message in messages { + out.extend_from_slice(message.to_string().as_bytes()); + out.push(b'\n'); + } + out +} + +fn rejection(stdout: &[u8]) -> String { + match locate_binary(stdout, &expected()) { + Ok(path) => panic!("should reject the output, got {path:?}"), + Err(error) => format!("{error:?}"), + } +} + +fn modified(edit: impl FnOnce(&mut Value)) -> Vec { + let mut message = artifact(); + edit(&mut message); + lines(&[build_script(), message, finished(&json!(true))]) +} + +#[test] +fn single_fresh_false_debuginfo_one_artifact_is_located() { + let stdout = lines(&[ + build_script(), + json!({"reason": "build-script-executed", "linked_libs": [], "env": []}), + artifact(), + finished(&json!(true)), + ]); + assert_eq!( + locate_binary(&stdout, &expected()).expect("should locate the executable"), + Path::new("/ts/writable/1/wasm32-example/release/example-bin.wasm"), + "should return the container executable path" + ); +} + +#[test] +fn build_must_end_with_successful_build_finished() { + let cases: [(&str, Vec); 6] = [ + ("missing", lines(&[build_script(), artifact()])), + ("empty", Vec::new()), + ("failed", lines(&[artifact(), finished(&json!(false))])), + ( + "non-boolean success", + lines(&[artifact(), finished(&json!("true"))]), + ), + ("not last", lines(&[finished(&json!(true)), artifact()])), + ( + "twice", + lines(&[artifact(), finished(&json!(true)), finished(&json!(true))]), + ), + ]; + for (label, stdout) in cases { + assert!( + rejection(&stdout).contains("build-finished"), + "should reject a {label} build-finished" + ); + } + assert!( + rejection(&lines(&[artifact(), json!({"reason": "build-finished"})])) + .contains("build-finished"), + "should reject build-finished without success" + ); +} + +#[test] +fn zero_or_duplicate_matching_artifacts_are_rejected() { + let none = lines(&[build_script(), finished(&json!(true))]); + assert!( + rejection(&none).contains("exactly one"), + "should reject zero binary artifacts" + ); + let twice = lines(&[artifact(), artifact(), finished(&json!(true))]); + assert!( + rejection(&twice).contains("exactly one"), + "should reject two binary artifacts" + ); + let mut stray = artifact(); + stray["target"]["name"] = json!("other-bin"); + stray["executable"] = json!(format!("{RELEASE}/other-bin.wasm")); + let extra = lines(&[artifact(), stray, finished(&json!(true))]); + assert!( + rejection(&extra).contains("unexpected"), + "should reject an additional binary artifact" + ); +} + +#[test] +fn wrong_kind_name_or_manifest_is_rejected() { + let edits: [(&str, Edit); 6] = [ + ("lib kind", |m| m["target"]["kind"] = json!(["lib"])), + ("bin and lib kind", |m| { + m["target"]["kind"] = json!(["bin", "lib"]) + }), + ("string kind", |m| m["target"]["kind"] = json!("bin")), + ("other name", |m| m["target"]["name"] = json!("other-bin")), + ("other manifest", |m| { + m["manifest_path"] = json!("/ts/immutable/0/crates/other/Cargo.toml"); + }), + ("relative manifest", |m| { + m["manifest_path"] = json!("crates/example-adapter/Cargo.toml"); + }), + ]; + for (label, edit) in edits { + assert!( + rejection(&modified(edit)).contains("exactly one"), + "should reject an artifact with {label}" + ); + } +} + +#[test] +fn fresh_or_unmeasured_artifact_is_rejected() { + let edits: [(&str, Edit); 4] = [ + ("fresh true", |m| m["fresh"] = json!(true)), + ("fresh missing", |m| { + m.as_object_mut().expect("should be object").remove("fresh"); + }), + ("fresh string", |m| m["fresh"] = json!("false")), + ("fresh null", |m| m["fresh"] = Value::Null), + ]; + for (label, edit) in edits { + assert!( + rejection(&modified(edit)).contains("fresh"), + "should reject an artifact with {label}" + ); + } +} + +#[test] +fn debuginfo_must_be_exactly_one() { + let edits: [(&str, Edit); 6] = [ + ("zero", |m| m["profile"]["debuginfo"] = json!(0)), + ("two", |m| m["profile"]["debuginfo"] = json!(2)), + ("string one", |m| m["profile"]["debuginfo"] = json!("1")), + ("float one", |m| m["profile"]["debuginfo"] = json!(1.5)), + ("missing", |m| { + m["profile"] + .as_object_mut() + .expect("should be object") + .remove("debuginfo"); + }), + ("no profile", |m| { + m.as_object_mut() + .expect("should be object") + .remove("profile"); + }), + ]; + for (label, edit) in edits { + assert!( + rejection(&modified(edit)).contains("debuginfo"), + "should reject an artifact with {label} debuginfo" + ); + } +} + +#[test] +fn executable_must_be_a_direct_child_of_the_release_directory() { + let cases: [(&str, Value); 9] = [ + ("null", Value::Null), + ("number", json!(7)), + ("relative", json!("release/example-bin.wasm")), + ("outside", json!("/ts/immutable/0/example-bin.wasm")), + ( + "sibling prefix", + json!("/ts/writable/1/wasm32-example/release-evil/x"), + ), + ("nested", json!(format!("{RELEASE}/deps/example-bin.wasm"))), + ("dotdot", json!(format!("{RELEASE}/../example-bin.wasm"))), + ("directory itself", json!(RELEASE)), + ( + "trailing slash", + json!(format!("{RELEASE}/example-bin.wasm/")), + ), + ]; + for (label, value) in cases { + let stdout = modified(|m| m["executable"] = value); + assert!( + rejection(&stdout).contains("executable"), + "should reject a {label} executable" + ); + } +} + +#[test] +fn malformed_lines_are_rejected() { + let good = lines(&[artifact(), finished(&json!(true))]); + let mut oversize = br#"{"reason":"build-script-executed","pad":""#.to_vec(); + oversize.resize(oversize.len() + 1024 * 1024, b'a'); + oversize.extend_from_slice(b"\"}\n"); + oversize.extend_from_slice(&good); + let cases: [(&str, Vec); 9] = [ + ( + "non-JSON", + [b"Compiling example\n".as_slice(), &good].concat(), + ), + ("blank line", [b"\n".as_slice(), &good].concat()), + ( + "duplicate keys", + [ + br#"{"reason":"build-script-executed","reason":"compiler-artifact"}"#.as_slice(), + b"\n", + &good, + ] + .concat(), + ), + ("array line", [b"[]\n".as_slice(), &good].concat()), + ("null line", [b"null\n".as_slice(), &good].concat()), + ( + "missing reason", + [b"{\"x\":1}\n".as_slice(), &good].concat(), + ), + ("oversize", oversize), + ( + "invalid UTF-8", + [b"{\"reason\":\"\xff\"}\n".as_slice(), &good].concat(), + ), + ("unterminated final line", good[..good.len() - 1].to_vec()), + ]; + for (label, stdout) in cases { + let message = rejection(&stdout); + assert!( + message.contains("line") || message.contains("Document"), + "should reject {label} output: {message}" + ); + } +} + +#[test] +fn host_path_maps_only_through_writable_roots() { + let host = Path::new("/host/target"); + let paths = PrefixPaths::new() + .with(Path::new("/host/checkout"), "/ts/immutable/0", false) + .with(host, "/ts/writable/1", true); + let located = locate_host_binary( + &lines(&[artifact(), finished(&json!(true))]), + &expected(), + &paths, + ) + .expect("should locate and map the executable"); + assert_eq!( + located.execution, + Path::new("/ts/writable/1/wasm32-example/release/example-bin.wasm"), + "should keep the container path" + ); + assert_eq!( + located.host, + Path::new("/host/target/wasm32-example/release/example-bin.wasm"), + "should map back into the writable host root" + ); + let immutable_only = + PrefixPaths::new().with(Path::new("/host/target"), "/ts/writable/1", false); + assert!( + locate_host_binary( + &lines(&[artifact(), finished(&json!(true))]), + &expected(), + &immutable_only + ) + .is_err(), + "should reject an executable that maps only through a non-writable root" + ); +} diff --git a/crates/trusted-server-release/src/recipe/compare.rs b/crates/trusted-server-release/src/recipe/compare.rs new file mode 100644 index 000000000..c3ce1aef3 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/compare.rs @@ -0,0 +1,242 @@ +//! Complete final-byte comparison of the canonical and reproduction Fastly +//! builds. +//! +//! The Fastly executable has no normalized or excluded region: the two final +//! files must be identical byte for byte. Before comparing bytes, the pair +//! must be one canonical and one reproduction build of the same asset from +//! the same source, `Cargo.lock`, vendored materials, JS handoff and Rust +//! tools, and their ambient records must vary exactly what the standard claim +//! states. + +use std::io::Read as _; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; +use wasmparser::{Parser, Payload}; + +use super::ambient::{AmbientClaim, AmbientRecord}; +use super::artifact::EXECUTABLE_LIMIT; +use super::{AssetId, RecipeCompleted, Role, recipe_error}; +use crate::error::ReleaseError; + +/// The only asset whose canonical and reproduction outputs must be byte-identical. +pub(crate) const FASTLY_ASSET: &str = "trusted-server-fastly.wasm"; +/// Bytes shown from each side at the first difference. +const WINDOW: usize = 16; + +/// Proof that the canonical and reproduction Fastly builds produced identical +/// final bytes. +/// +/// It has private fields and only [`check_fastly_equality`] constructs it. +#[derive(Debug)] +pub(crate) struct FastlyEqualityChecked { + asset: AssetId, + sha256: String, + size: u64, + ambient: AmbientRecord, +} + +impl FastlyEqualityChecked { + /// The compared asset. + pub(crate) fn asset(&self) -> AssetId { + self.asset + } + + /// SHA-256 of the identical final bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Size of the identical final bytes. + pub(crate) fn size(&self) -> u64 { + self.size + } + + /// The verified ambient pairing of the two builds. + pub(crate) fn ambient(&self) -> &AmbientRecord { + &self.ambient + } +} + +/// Requires `canonical` and `reproduction` to be equal, naming `label` otherwise. +fn same_input( + label: &str, + canonical: &T, + reproduction: &T, +) -> Result<(), Report> { + if canonical == reproduction { + return Ok(()); + } + Err(recipe_error(format!( + "canonical and reproduction builds used different {label}" + ))) +} + +/// Requires one canonical and one reproduction build of `asset` over +/// identical inputs. +pub(super) fn check_pair( + canonical: &RecipeCompleted, + reproduction: &RecipeCompleted, + asset: AssetId, +) -> Result<(), Report> { + if canonical.role() != Role::Canonical || reproduction.role() != Role::Reproduction { + return Err(recipe_error( + "a comparison needs a canonical then a reproduction build", + )); + } + if canonical.asset() != asset || reproduction.asset() != asset { + return Err(recipe_error(format!( + "this comparison compares only {}", + asset.artifact().name + ))); + } + same_input("source commits", canonical.commit(), reproduction.commit())?; + same_input( + "Cargo.lock files", + canonical.cargo_lock_sha256(), + reproduction.cargo_lock_sha256(), + )?; + same_input( + "vendored materials", + canonical.materials_digest(), + reproduction.materials_digest(), + )?; + same_input( + "producer-normalized Git manifests", + canonical.normalized_manifest_digests(), + reproduction.normalized_manifest_digests(), + )?; + same_input( + "derived Git manifests", + canonical.derived_manifest_digests(), + reproduction.derived_manifest_digests(), + )?; + same_input( + "JS handoffs", + &canonical.js_digests(), + &reproduction.js_digests(), + )?; + same_input( + "Rust tools", + canonical.rust_tools_digest(), + reproduction.rust_tools_digest(), + ) +} + +/// Reads the retained artifact and requires its recorded size and digest. +pub(super) fn read_artifact(completed: &RecipeCompleted) -> Result, Report> { + completed.rehash_for_handoff()?; + let artifact = completed.artifact(); + if artifact.size() > EXECUTABLE_LIMIT { + return Err(recipe_error("artifact exceeds the executable limit")); + } + let mut bytes = Vec::new(); + std::fs::File::open(artifact.path()) + .and_then(|file| { + file.take(EXECUTABLE_LIMIT.saturating_add(1)) + .read_to_end(&mut bytes) + }) + .map_err(|error| recipe_error(format!("cannot read the final artifact: {error}")))?; + if bytes.len() as u64 != artifact.size() + || hex::encode(Sha256::digest(&bytes)) != artifact.sha256() + { + return Err(recipe_error("final artifact changed after its rehash")); + } + Ok(bytes) +} + +/// Compares the complete final Fastly files of a canonical and a reproduction +/// build. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the pair is not the canonical then +/// the reproduction build of the Fastly asset, the builds used different +/// source, lock, materials, normalized or derived manifests, JS handoff or +/// tools, the ambient records do not vary exactly what +/// [`AmbientClaim::standard`] states, an artifact no longer rehashes, or the +/// final bytes differ; a difference reports both sizes and digests, the first +/// differing offset, the bytes there and their section. +pub(crate) fn check_fastly_equality( + canonical: &RecipeCompleted, + reproduction: &RecipeCompleted, +) -> Result> { + check_pair(canonical, reproduction, AssetId::parse(FASTLY_ASSET)?)?; + let ambient = AmbientRecord::pair( + canonical.ambient(), + reproduction.ambient(), + &AmbientClaim::standard(), + )?; + let canonical_bytes = read_artifact(canonical)?; + let reproduction_bytes = read_artifact(reproduction)?; + if canonical_bytes != reproduction_bytes { + return Err(recipe_error(describe_mismatch( + &canonical_bytes, + &reproduction_bytes, + ))); + } + Ok(FastlyEqualityChecked { + asset: canonical.asset(), + sha256: canonical.artifact().sha256().to_owned(), + size: canonical.artifact().size(), + ambient, + }) +} + +/// Returns the first offset where `first` and `second` differ, if any. +/// +/// When one is a strict prefix of the other, the offset is the shorter length. +pub(super) fn first_difference(first: &[u8], second: &[u8]) -> Option { + first + .iter() + .zip(second) + .position(|(left, right)| left != right) + .or_else(|| (first.len() != second.len()).then(|| first.len().min(second.len()))) +} + +/// Names the Wasm section whose payload holds `offset`, if the module parses. +fn section_at(bytes: &[u8], offset: usize) -> String { + for payload in Parser::new(0).parse_all(bytes) { + let Ok(payload) = payload else { + return "unparsed module".to_owned(); + }; + let Some((id, range)) = payload.as_section() else { + continue; + }; + if range.contains(&offset) { + return match payload { + Payload::CustomSection(reader) => format!("custom section {:?}", reader.name()), + _ => format!("section id {id}"), + }; + } + } + "no section payload".to_owned() +} + +/// Describes a final-byte mismatch with bounded diagnostics. +pub(super) fn describe_mismatch(canonical: &[u8], reproduction: &[u8]) -> String { + let offset = first_difference(canonical, reproduction).unwrap_or(canonical.len()); + let window = |bytes: &[u8]| { + let end = bytes.len().min(offset.saturating_add(WINDOW)); + hex::encode(bytes.get(offset..end).unwrap_or_default()) + }; + let differing = canonical + .iter() + .zip(reproduction) + .filter(|(left, right)| left != right) + .count() + + canonical.len().abs_diff(reproduction.len()); + format!( + "final Fastly bytes differ: canonical size {} sha256 {}, \ + reproduction size {} sha256 {}; first differing offset {offset} \ + (canonical {}: {}, reproduction {}: {}); {differing} differing bytes", + canonical.len(), + hex::encode(Sha256::digest(canonical)), + reproduction.len(), + hex::encode(Sha256::digest(reproduction)), + section_at(canonical, offset), + window(canonical), + section_at(reproduction, offset), + window(reproduction), + ) +} diff --git a/crates/trusted-server-release/src/recipe/completed.rs b/crates/trusted-server-release/src/recipe/completed.rs new file mode 100644 index 000000000..7f4da7983 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/completed.rs @@ -0,0 +1,428 @@ +//! The proof a recipe build ran to completion, with its copied artifact. +//! +//! [`RecipeCompleted`] has private fields and no public constructor: +//! [`RecipeCompleted::record_success`] is called only from the recipe's +//! successful-run path, after Cargo's output was parsed and the artifact was +//! copied and inspected. It binds everything an attestation needs and can +//! re-measure the retained output before handoff. + +use std::fs; +use std::io::Write as _; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::artifact::{CopiedArtifact, EXECUTABLE_LIMIT}; +use super::{ + AssetId, CheckedAmbient, ExactInvocationDigest, RecipeDefinitionIdentity, Role, recipe_error, +}; +use crate::boundary::{DenialReceipt, DescendantCleanup}; +use crate::composition::{CargoCapture, CompositionView, RetainedCompositionExport}; +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; +use crate::javascript::contributions::RetainedContributions; +use crate::source::CommitId; + +/// File name of the retained Cargo message stream. +const STDOUT_FILE: &str = "cargo-stdout.jsonl"; +/// Original JS observer bytes, retained independently of the preparation lease. +const JS_INVENTORY_FILE: &str = "tsjs-inventory.json"; + +/// Facts about one successful run, supplied by the recipe's run path. +pub(super) struct SuccessFacts { + pub(super) asset: AssetId, + pub(super) role: Role, + pub(super) commit: CommitId, + pub(super) cargo_lock_sha256: String, + pub(super) materials_digest: String, + pub(super) js_inventory_sha256: String, + pub(super) js_settings_sha256: String, + pub(super) js_tools_sha256: String, + pub(super) js_contributions: Option, + pub(super) rust_tools_digest: String, + pub(super) boundary_fingerprint: [u8; 32], + pub(super) denial_receipt: Option, + pub(super) recipe_definition: Option, + pub(super) plan_digest: ExactInvocationDigest, + pub(super) ambient: CheckedAmbient, + pub(super) started_at_seconds: u64, + pub(super) elapsed: Duration, + pub(super) descendant_cleanup: DescendantCleanup, + pub(super) stdout: Vec, + pub(super) stderr_sha256: String, + pub(super) artifact: CopiedArtifact, + pub(super) output_dir: PathBuf, + pub(super) normalized_manifest_digests: std::collections::BTreeMap, + pub(super) derived_manifest_digests: std::collections::BTreeMap, + pub(super) cargo_observations: Option, +} + +/// A built, copied and bound artifact. +pub(crate) struct RecipeCompleted { + facts: SuccessFacts, + stdout_sha256: String, + retained: tempfile::TempDir, +} + +impl std::fmt::Debug for RecipeCompleted { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RecipeCompleted") + .field("asset", &self.facts.asset) + .field("role", &self.facts.role) + .field("artifact", &self.facts.artifact) + .field("plan_digest", &self.facts.plan_digest) + .finish_non_exhaustive() + } +} + +impl RecipeCompleted { + /// Returns the approved source-owned definition retained by this build. + /// + /// This selects recipe policy without proving tool qualification or which + /// bootstrap built the producer that executed it. + /// + /// # Errors + /// + /// Rejects changed outputs, an unbound fixture or another source/asset selection. + pub(crate) fn recipe_definition( + &self, + ) -> Result<&RecipeDefinitionIdentity, Report> { + self.rehash_for_handoff()?; + self.facts + .recipe_definition + .as_ref() + .filter(|definition| definition.matches_selection(&self.facts.commit, self.facts.asset)) + .ok_or_else(|| { + recipe_error("completed build has no matching approved recipe definition") + }) + } + + /// Returns measured socket observations bound to this completed execution. + /// + /// This diagnostic receipt does not reconstruct a network-denial capability. + /// + /// # Errors + /// + /// Rejects changed retained outputs, an unmeasured fixture or another boundary. + pub(crate) fn denial_receipt(&self) -> Result<&DenialReceipt, Report> { + self.rehash_for_handoff()?; + self.facts + .denial_receipt + .as_ref() + .filter(|receipt| receipt.matches_fingerprint(&self.facts.boundary_fingerprint)) + .ok_or_else(|| recipe_error("completed build has no matching measured socket receipt")) + } + + /// Records one successful run and retains its Cargo message stream. + /// + /// Only the recipe's successful-run path calls this. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the stream cannot be retained. + pub(super) fn record_success(facts: SuccessFacts) -> Result> { + let retained = tempfile::tempdir() + .map_err(|error| recipe_error(format!("cannot retain Cargo output: {error}")))?; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(retained.path().join(STDOUT_FILE)) + .map_err(|error| recipe_error(format!("cannot retain Cargo output: {error}")))?; + file.write_all(&facts.stdout) + .and_then(|()| file.sync_all()) + .map_err(|error| recipe_error(format!("cannot retain Cargo output: {error}")))?; + let stdout_sha256 = hex::encode(Sha256::digest(&facts.stdout)); + if let Some(captured) = &facts.cargo_observations { + captured.retain(retained.path())?; + captured.rehash(retained.path())?; + } + if let Some(javascript) = &facts.js_contributions { + check_javascript_binding(&facts, javascript)?; + let mut inventory = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(retained.path().join(JS_INVENTORY_FILE)) + .map_err(|error| recipe_error(format!("cannot retain JS inventory: {error}")))?; + inventory + .write_all(javascript.raw_inventory()) + .and_then(|()| inventory.sync_all()) + .map_err(|error| recipe_error(format!("cannot retain JS inventory: {error}")))?; + } + Ok(Self { + facts, + stdout_sha256, + retained, + }) + } + + /// The asset that was built. + pub(crate) fn asset(&self) -> AssetId { + self.facts.asset + } + + /// The role of this build. + pub(crate) fn role(&self) -> Role { + self.facts.role + } + + /// The approved source commit that was built. + pub(crate) fn commit(&self) -> &CommitId { + &self.facts.commit + } + + /// SHA-256 of the approved raw `Cargo.lock`. + pub(crate) fn cargo_lock_sha256(&self) -> &str { + &self.facts.cargo_lock_sha256 + } + + /// Digest of the vendored dependency tree. + pub(crate) fn materials_digest(&self) -> &str { + &self.facts.materials_digest + } + + /// Digests of the Git manifests the producer normalized, by vendor directory. + /// + /// Each was derived from the verified Git tree alone by the producer's + /// reviewed normalization; on their own they do not make the build + /// unqualified. They are also covered by the materials digest. + pub(crate) fn normalized_manifest_digests( + &self, + ) -> &std::collections::BTreeMap { + &self.facts.normalized_manifest_digests + } + + /// Digests of caller-derived Git manifests the build consumed, by vendor directory. + /// + /// Production vendoring accepts no caller-supplied manifest, so this is + /// empty there. A non-empty set marks the build as built from derived + /// inputs, so it is unqualified, and a qualified authority must refuse + /// to attest it. + pub(crate) fn derived_manifest_digests(&self) -> &std::collections::BTreeMap { + &self.facts.derived_manifest_digests + } + + /// The JS handoff digests: inventory, settings and tools, in that order. + pub(crate) fn js_digests(&self) -> [&str; 3] { + [ + &self.facts.js_inventory_sha256, + &self.facts.js_settings_sha256, + &self.facts.js_tools_sha256, + ] + } + + /// Digest of the Rust tool tree measured at binding. + pub(crate) fn rust_tools_digest(&self) -> &str { + &self.facts.rust_tools_digest + } + + /// Execution fingerprint of the boundary instance that ran the build. + pub(crate) fn boundary_fingerprint(&self) -> [u8; 32] { + self.facts.boundary_fingerprint + } + + /// Digest of the exact Cargo plan that ran. + pub(crate) fn plan_digest(&self) -> &str { + self.facts.plan_digest.sha256() + } + + /// The ambient conditions the build ran under. + pub(crate) fn ambient(&self) -> &CheckedAmbient { + &self.facts.ambient + } + + /// Unix start time of the build in seconds. + pub(crate) fn started_at_seconds(&self) -> u64 { + self.facts.started_at_seconds + } + + /// How the boundary that ran the build ended its descendants. + pub(crate) fn descendant_cleanup(&self) -> DescendantCleanup { + self.facts.descendant_cleanup + } + + /// Wall time the build took. + pub(crate) fn elapsed(&self) -> Duration { + self.facts.elapsed + } + + /// SHA-256 of Cargo's message stream. + pub(crate) fn stdout_sha256(&self) -> &str { + &self.stdout_sha256 + } + + /// SHA-256 of the captured diagnostics stream. + pub(crate) fn stderr_sha256(&self) -> &str { + &self.facts.stderr_sha256 + } + + /// Supporting Cargo observations; absent only in pure completion fixtures. + pub(crate) fn cargo_observations_summary(&self) -> Option { + self.facts + .cargo_observations + .as_ref() + .map(CargoCapture::summary) + } + + /// Exports exact composition receipts bound to this completed build. + /// + /// # Errors + /// + /// Rejects changed owned outputs, missing composition, or summary-only + /// observations. The export preserves existing incomplete coverage. + pub(crate) fn composition_export( + &self, + ) -> Result> { + self.rehash_for_handoff()?; + self.facts + .cargo_observations + .as_ref() + .ok_or_else(|| recipe_error("completed build has no retained composition"))? + .export(self.retained.path()) + } + + /// Borrows sealed typed composition after rechecking this completed build. + /// + /// # Errors + /// + /// Rejects changed owned outputs or missing actual composition receipts. + pub(crate) fn composition_view(&self) -> Result, Report> { + self.rehash_for_handoff()?; + self.facts + .cargo_observations + .as_ref() + .ok_or_else(|| recipe_error("completed build has no retained composition"))? + .view(self.retained.path()) + } + + /// Borrows checked original JS evidence retained when the recipe was bound. + /// + /// This snapshot survives cleanup and expiry of its original preparation + /// attempt. It grants no new execution or composition-coverage authority. + /// + /// # Errors + /// + /// Rejects missing evidence, changed retained bytes or another source/digest. + pub(crate) fn javascript_contributions( + &self, + ) -> Result<&RetainedContributions, Report> { + self.rehash_for_handoff()?; + self.facts + .js_contributions + .as_ref() + .ok_or_else(|| recipe_error("completed build has no retained original JS inventory")) + } + + /// The copied artifact. + pub(crate) fn artifact(&self) -> &CopiedArtifact { + &self.facts.artifact + } + + /// Reads the retained final bytes after rechecking their size and digest. + /// + /// # Errors + /// + /// Rejects changed owned outputs, an oversized artifact or bytes whose size + /// or SHA-256 differ from the recorded artifact. + pub(crate) fn read_final_bytes(&self) -> Result, Report> { + super::compare::read_artifact(self) + } + + /// Re-measures the output directory and retained stream before handoff. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the output directory holds + /// anything but the artifact, the artifact is not one regular file with + /// one link and the recorded size and SHA-256, or the retained Cargo + /// stream no longer matches its digest. + pub(crate) fn rehash_for_handoff(&self) -> Result<&Path, Report> { + let artifact = &self.facts.artifact; + let names = fs::read_dir(&self.facts.output_dir) + .map_err(|error| recipe_error(format!("output cannot be listed: {error}")))? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::, _>>() + .map_err(|error| recipe_error(format!("output cannot be listed: {error}")))?; + if names != [std::ffi::OsString::from(artifact.name())] { + return Err(recipe_error( + "output directory holds more than the artifact", + )); + } + let metadata = fs::symlink_metadata(artifact.path()) + .map_err(|error| recipe_error(format!("artifact cannot be inspected: {error}")))?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(recipe_error("artifact is not one regular unlinked file")); + } + let measured = hash_bounded_with_check(artifact.path(), EXECUTABLE_LIMIT, &|| Ok(())) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + if measured.size != artifact.size() || measured.sha256 != artifact.sha256() { + return Err(recipe_error("artifact bytes changed after the copy")); + } + let stream = hash_bounded_with_check( + &self.retained.path().join(STDOUT_FILE), + 64 * 1024 * 1024, + &|| Ok(()), + ) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + if stream.sha256 != self.stdout_sha256 { + return Err(recipe_error("retained Cargo output changed")); + } + if let Some(captured) = &self.facts.cargo_observations { + captured.rehash(self.retained.path())?; + } + if let Some(javascript) = &self.facts.js_contributions { + check_javascript_binding(&self.facts, javascript)?; + let measured = hash_bounded_with_check( + &self.retained.path().join(JS_INVENTORY_FILE), + javascript.raw_inventory().len() as u64, + &|| Ok(()), + ) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + if measured.size != javascript.raw_inventory().len() as u64 + || measured.sha256 != javascript.inventory_sha256() + { + return Err(recipe_error("retained original JS inventory changed")); + } + } + Ok(&self.facts.output_dir) + } +} + +fn check_javascript_binding( + facts: &SuccessFacts, + javascript: &RetainedContributions, +) -> Result<(), Report> { + if javascript.source_commit() != facts.commit.as_str() + || javascript.inventory_sha256() != facts.js_inventory_sha256 + || hex::encode(Sha256::digest(javascript.raw_inventory())) != facts.js_inventory_sha256 + { + return Err(recipe_error( + "retained JS inventory source or digest differs", + )); + } + Ok(()) +} + +#[cfg(test)] +impl RecipeCompleted { + /// Path of original retained JS observer bytes, for tamper tests. + pub(crate) fn retained_inventory_path(&self) -> PathBuf { + self.retained.path().join(JS_INVENTORY_FILE) + } + + /// Path of the retained Cargo message stream, for tamper tests. + pub(crate) fn retained_stdout_path(&self) -> PathBuf { + self.retained.path().join(STDOUT_FILE) + } + + /// Bound facts, for tests that forge one mismatched input. + pub(super) fn facts_mut_for_tests(&mut self) -> &mut SuccessFacts { + &mut self.facts + } +} diff --git a/crates/trusted-server-release/src/recipe/execute.rs b/crates/trusted-server-release/src/recipe/execute.rs new file mode 100644 index 000000000..847859377 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/execute.rs @@ -0,0 +1,672 @@ +//! The confined run of a bound recipe and the checks around it. +//! +//! Every input is rehashed immediately before and after the confined Cargo +//! process, Cargo home must gain no fetched registry or Git sources, and only a +//! successful run is parsed, copied and bound into a [`RecipeCompleted`]. + +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::apple::AppleDeveloperTools; +use super::artifact::{EXECUTABLE_LIMIT, copy_artifact}; +use super::cargo_output::{ExpectedBinary, locate_host_binary}; +use super::completed::SuccessFacts; +use super::gnu::{GnuEvidence, MeasurementSandbox}; +use super::plan::CargoQuery; +use super::{ + Authority, ExactInvocationDigest, ExecutionPaths, PreparedRecipe, RecipeCompleted, + SelectedRustTools, WorkspaceSelection, recipe_error, +}; +use crate::boundary::{Boundary, DenialProof, DenialReceipt, DescendantCleanup}; +use crate::composition::external::ExternalRequirements; +use crate::composition::javascript::{JsInputs, JsRetentionPolicy, collect_js}; +use crate::composition::native::{self, NativeInputs}; +use crate::composition::origin::join_origins; +use crate::composition::platform::PlatformEvidence; +use crate::composition::{CargoCapture, CargoLayout}; +use crate::documents::{Role as DocumentRole, read_bounded}; +use crate::error::ReleaseError; +use crate::inspect::{ElfPolicy, MachoCpu, MachoPolicy, inspect_elf, inspect_macho, inspect_wasm}; +use crate::policy::ReleaseTarget; +use crate::process::ProcessLimits; +use crate::tool_tree::tool_tree_digest; + +/// Captured output limit of each Cargo stream. +const BUILD_STREAM_MAX: u64 = 16 * 1024 * 1024; + +/// Limits of the confined Cargo process under the selection's build wall. +fn build_limits(tools: &SelectedRustTools) -> ProcessLimits { + ProcessLimits { + wall: tools.build_wall(), + stdout_max: BUILD_STREAM_MAX, + stderr_max: BUILD_STREAM_MAX, + } +} + +/// What a successful confined run produced, as the run path reports it. +pub(super) struct RunOutput { + pub(super) gnu: Option, + pub(super) stdout: Vec, + pub(super) stderr_sha256: String, + pub(super) started_at_seconds: u64, + pub(super) elapsed: Duration, + /// The descendant cleanup of the boundary that ran the build. + pub(super) cleanup: DescendantCleanup, + /// Supporting output captured by this same denied recipe invocation. + pub(super) queries: CargoQueryOutput, +} + +/// Supporting observations from execution, or an explicit pure-test fixture. +pub(super) enum CargoQueryOutput { + /// Raw metadata and tree output from the selected Cargo under the proof. + Captured { + metadata: Vec, + tree: Vec, + plan_digests: [String; 2], + denial: DenialReceipt, + fastly_tools: Option>, + }, + /// Pure completion tests never execute Cargo and cannot supply real observations. + #[cfg(test)] + Fixture, +} + +/// Admits a boundary's descendant cleanup for a selection. +/// +/// Container-owned cleanup is always admitted. Post-run-reaped cleanup, +/// where survivors exist until the reap after the run, is admitted only when +/// `allowance` (the selection's +/// [`permits_post_run_reaped_cleanup`](super::SelectedRustTools::permits_post_run_reaped_cleanup)) +/// holds. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for post-run-reaped cleanup without the +/// allowance. +pub(super) fn cleanup_gate( + cleanup: DescendantCleanup, + allowance: bool, +) -> Result<(), Report> { + match cleanup { + DescendantCleanup::OwnedByContainer => Ok(()), + DescendantCleanup::ReapedAfterRun if allowance => Ok(()), + DescendantCleanup::ReapedAfterRun => Err(recipe_error( + "post-run-reaped cleanup is not permitted for this selection", + )), + } +} + +impl PreparedRecipe { + /// Requires the tools to be the tree measured at binding and any Apple + /// developer tools to still match their record. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when anything under the tool root was + /// edited, linked, added or removed, or an Apple tool, the SDK settings, + /// the detached SDK or the measured toolchain changed. + pub(super) fn check_tools(&self) -> Result<(), Report> { + self.check_rust_tools()?; + self.tools + .apple() + .map_or(Ok(()), super::apple::AppleDeveloperTools::verify) + } + + /// Requires the Rust tools to be the tree measured at binding. + /// + /// Unlike [`Self::check_tools`] this never rehashes the Apple developer + /// tools. Callers use it only where a full [`Self::check_inputs`] + /// brackets the same step: before the run (followed by the pre-run + /// inputs check) and at completion (preceded by the post-run one). + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when anything under the tool root was + /// edited, linked, added or removed. + pub(super) fn check_rust_tools(&self) -> Result<(), Report> { + if self.tools.digest()? != self.tools_digest { + return Err(recipe_error("Rust tools changed after binding")); + } + Ok(()) + } + + /// Requires the prepared materials to be unchanged. + pub(super) fn check_materials(&self) -> Result<(), Report> { + self.materials.rehash().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("prepared materials changed after binding") + }) + } + + /// Requires the retained JS handoff to be the tree measured at binding. + fn check_js_handoff(&self) -> Result<(), Report> { + if retained_tree_digest(&self.retained_root)? != self.retained_digest { + return Err(recipe_error("retained JS handoff changed after binding")); + } + Ok(()) + } + + /// Rechecks source and rehashes the JS handoff, tools and materials. + pub(super) fn check_inputs(&self) -> Result<(), Report> { + self.check_source()?; + self.check_js_handoff()?; + self.check_tools()?; + self.check_materials() + } + + /// Rechecks source while its working root still exists, before receipt sealing. + fn check_source(&self) -> Result<(), Report> { + match &self.workspace { + WorkspaceSelection::Approved(workspace) => workspace.origins.verify_unchanged(), + #[cfg(test)] + WorkspaceSelection::Fixture => Ok(()), + } + } + + /// Requires Cargo's private home to hold no registry or Git cache. + /// + /// Cargo may initialize its registry directory and cache tag before source + /// replacement. Only that exact bookkeeping is admitted; source/cache/index + /// entries and every Git cache remain forbidden. + pub(super) fn check_no_fetched_sources(&self) -> Result<(), Report> { + super::cargo_home::check(&self.request.cargo_home(), &|| Ok(())) + } + + /// Requires unchanged inputs, a derivable plan, the exact planned roots + /// and a descendant cleanup the selection accepts; returns that cleanup. + /// + /// Only the Rust tools are rehashed here. The Apple developer tools are + /// rehashed by the full [`Self::check_inputs`] that [`Self::execute`] + /// runs next, with only pure plan and command derivation in between, so + /// a pass here would hash the same SDK and toolchain twice. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the Rust tools or materials + /// changed, the plan cannot be derived in the boundary's namespace, the + /// boundary roots differ from [`Self::boundary_roots`], or + /// [`cleanup_gate`] rejects the boundary's descendant cleanup. + fn check_boundary( + &self, + boundary: &Boundary, + ) -> Result> { + self.check_rust_tools()?; + self.check_materials()?; + self.plan(boundary)?; + if !boundary.matches_roots(&self.boundary_roots()?) { + return Err(recipe_error( + "boundary roots differ from the exact recipe roots", + )); + } + let cleanup = boundary.descendant_cleanup(); + cleanup_gate(cleanup, self.tools.permits_post_run_reaped_cleanup())?; + Ok(cleanup) + } + + /// Checks every gate, runs Cargo confined and completes the run. + /// + /// Only a selection that [permits a candidate run](super::SelectedRustTools::permits_candidate_run) + /// reaches the run: the test-only ordinary CI selection. That run is + /// unqualified CI evidence; production builds can name no selection, so + /// their gate stays closed. The JS handoff, tools and materials are + /// rehashed immediately before and after the run, and Cargo home must + /// gain no fetched sources beyond exact Cargo registry bookkeeping. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when an input, plan, roots or + /// descendant cleanup gate fails, an input changed around the run, Cargo + /// fetched into its home, the build did not succeed or its completion is + /// rejected; [`ReleaseError::Unqualified`] for a selection that permits no + /// candidate run; or [`ReleaseError::Boundary`] / [`ReleaseError::Process`] + /// when the confined run itself fails. + pub(crate) fn execute( + self, + boundary: &Boundary, + proof: &DenialProof, + ) -> Result> { + let cleanup = self.check_boundary(boundary)?; + let command = self + .plan(boundary)? + .confined_command(build_limits(&self.tools))?; + if !self.tools.permits_candidate_run() { + return Err(Report::new(ReleaseError::Unqualified) + .attach("Cargo execution is closed until Rust tools and boundary are qualified")); + } + self.check_inputs()?; + self.check_no_fetched_sources()?; + // GNU measurements run in a sibling sandbox without the target root: + // after the build that root holds Cargo's in-root hard links, which + // the build boundary's pre-run single-link scan rejects. + let gnu_sandbox = + if self.request.asset.artifact().target == ReleaseTarget::X86_64UnknownLinuxGnu { + Some(MeasurementSandbox::establish( + boundary, + &self.tools, + &self.request.target_dir, + )?) + } else { + None + }; + let gnu = gnu_sandbox + .as_ref() + .map(|sandbox| { + GnuEvidence::measure( + &self.tools, + &self.plan(boundary)?, + &boundary.execution_fingerprint(), + sandbox, + ) + }) + .transpose()?; + let fastly_sandbox = if self.request.asset.artifact().target == ReleaseTarget::Wasm32Wasip1 + && cfg!(all(target_os = "linux", target_arch = "x86_64")) + { + Some(MeasurementSandbox::establish( + boundary, + &self.tools, + &self.request.target_dir, + )?) + } else { + None + }; + let fastly_tools = fastly_sandbox + .as_ref() + .map(|sandbox| { + super::fastly_tools::FastlyToolEvidence::measure( + &self.tools, + &self.plan(boundary)?, + &boundary.execution_fingerprint(), + sandbox, + ) + }) + .transpose()?; + let denial = proof.receipt_for(&boundary.execution_fingerprint())?; + let run: Result<_, Report> = (|| { + let query = |kind| { + let plan = self.plan(boundary)?.observation_query(kind)?; + let limits = ProcessLimits { + wall: Duration::from_secs(120), + ..build_limits(&self.tools) + }; + let digest = plan.digest()?; + let stdout = boundary + .run_confined(&plan.confined_command(limits)?, proof)? + .require_success()?; + Ok::<_, Report>((stdout, digest)) + }; + let (metadata, metadata_plan) = query(CargoQuery::Metadata)?; + let (tree, tree_plan) = query(CargoQuery::Tree)?; + let started_at_seconds = unix_seconds()?; + let result = boundary.run_confined(&command, proof)?; + Ok(( + CargoQueryOutput::Captured { + metadata, + tree, + plan_digests: [metadata_plan, tree_plan], + denial, + fastly_tools: fastly_tools.map(Box::new), + }, + result, + started_at_seconds, + )) + })(); + let after = self + .check_inputs() + .and_then(|()| self.check_no_fetched_sources()) + .and_then(|()| { + if let (Some(before), Some(sandbox)) = (&gnu, &gnu_sandbox) { + let after = GnuEvidence::measure( + &self.tools, + &self.plan(boundary)?, + &boundary.execution_fingerprint(), + sandbox, + )?; + before.verify_after(&after)?; + } + if let ( + Some(sandbox), + Ok(( + CargoQueryOutput::Captured { + fastly_tools: Some(before), + .. + }, + _, + _, + )), + ) = (&fastly_sandbox, &run) + { + let after = super::fastly_tools::FastlyToolEvidence::measure( + &self.tools, + &self.plan(boundary)?, + &boundary.execution_fingerprint(), + sandbox, + )?; + before.verify_after(&after)?; + } + Ok(()) + }); + + let (queries, result, started_at_seconds) = match (run, after) { + (Ok(result), Ok(())) => result, + (Ok(_), Err(changed)) => return Err(changed), + (Err(run), Ok(())) => return Err(run), + (Err(run), Err(changed)) => { + return Err(run.attach(format!("inputs also failed their checks: {changed:?}"))); + } + }; + let stderr_sha256 = hex::encode(Sha256::digest(result.stderr())); + let elapsed = result.elapsed(); + let stdout = result.require_success().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("confined Cargo build did not succeed") + })?; + let output = RunOutput { + gnu, + stdout, + stderr_sha256, + started_at_seconds, + elapsed, + cleanup, + queries, + }; + self.complete(output, boundary.execution_fingerprint(), boundary) + } + + /// Turns one successful run into a [`RecipeCompleted`]. + /// + /// Parses Cargo's stream, maps the executable back through a writable + /// root, copies it into the output directory and inspects the copy: a + /// Wasm module, Linux or macOS executable must pass its inspector and + /// keep the debug sections or debug map a stripped file lacks (see + /// [`inspect_copied`]). Every target must also exclude all bound host and + /// execution roots and contain a fixed remapped prefix. + /// + /// Only the Rust tools are rehashed here, since the receipt binds their + /// digest. [`Self::execute`] already rehashed the Apple developer tools + /// in its post-run [`Self::check_inputs`], with only in-process checks + /// in between, and platform evidence rechecks them when it seals. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the Rust tools or materials changed, + /// the stream is not exactly one fresh debug-info binary, or the + /// executable cannot be copied or rehashed, a bound path remains, no fixed + /// remapped prefix exists, or [`ReleaseError::Inspect`] when the + /// Wasm module or Linux executable is rejected or stripped. + pub(super) fn complete( + self, + run: RunOutput, + boundary_fingerprint: [u8; 32], + paths: &dyn ExecutionPaths, + ) -> Result> { + self.check_rust_tools()?; + self.check_materials()?; + let plan_digest = ExactInvocationDigest::from_plan(&self.plan(paths)?)?; + let artifact = self.request.asset.artifact(); + let manifest = paths.execution_path( + &self + .ambient + .checkout_root() + .join("crates") + .join(artifact.package) + .join("Cargo.toml"), + )?; + let release_dir = paths + .execution_path(&self.request.target_dir)? + .join(artifact.target.triple()) + .join("release"); + let located = locate_host_binary( + &run.stdout, + &ExpectedBinary { + bin: artifact.bin, + manifest: &manifest, + release_dir: &release_dir, + }, + paths, + )?; + let (mut cargo_observations, denial_receipt, fastly_tools) = match run.queries { + CargoQueryOutput::Captured { + metadata, + tree, + plan_digests, + denial, + fastly_tools, + } => { + if !denial.matches_fingerprint(&boundary_fingerprint) { + return Err(recipe_error( + "socket observations belong to another execution boundary", + )); + } + let layout = CargoLayout::new( + artifact.target, + paths.execution_path(&self.request.target_dir)?, + paths.execution_path(self.ambient.checkout_root())?, + )?; + let captured = + CargoCapture::new(metadata, tree, &run.stdout, &layout, plan_digests)?; + let selected_package_id = + captured.require_binary(&manifest, artifact.bin, &located.execution)?; + ( + Some((captured, selected_package_id)), + Some(denial), + fastly_tools, + ) + } + #[cfg(test)] + CargoQueryOutput::Fixture => (None, None, None), + }; + let copied = copy_artifact( + &located.host, + &self.request.target_dir, + &self.request.output, + artifact.name, + EXECUTABLE_LIMIT, + )?; + let raw = read_bounded(copied.path(), DocumentRole::Executable) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + if raw.sha256 != copied.sha256() || raw.bytes.len() as u64 != copied.size() { + return Err(recipe_error( + "copied artifact changed before final-byte checks", + )); + } + let requirements = inspect_copied(artifact.target, &raw.bytes, self.tools.apple())?; + self.check_final_paths(&raw.bytes, paths)?; + + let cargo_observations = if let Some((captured, selected_package_id)) = + cargo_observations.take() + { + Some(match &self.workspace { + WorkspaceSelection::Approved(workspace) => { + let origins = join_origins( + captured.observations(), + &workspace.origins, + &self.materials, + paths, + )?; + let plan = self.plan(paths)?; + let native = native::collect( + captured.observations(), + &NativeInputs { + origins: origins.native_sources(), + execution_output_root: &paths + .execution_path(&self.request.target_dir)?, + host_output_root: &self.request.target_dir, + target: artifact.target, + environment: &plan.environment, + check: &|| Ok(()), + }, + )?; + let javascript = collect_js( + &raw.bytes, + copied.sha256(), + &JsInputs { + retained_root: &self.retained_root, + modules: &self.js.modules, + inventory_sha256: &self.js.inventory_sha256, + policy: if artifact.target == ReleaseTarget::Wasm32Wasip1 { + JsRetentionPolicy::FastlyAll + } else { + JsRetentionPolicy::ObserveCli + }, + }, + )?; + self.check_source()?; + let platform = PlatformEvidence::collect( + &self.tools, + &self.tools_digest, + requirements, + artifact.target, + copied.sha256(), + )?; + let platform = if artifact.target == ReleaseTarget::X86_64UnknownLinuxGnu { + let gnu = run.gnu.ok_or_else(|| { + recipe_error("GNU completion lacks enforced in-image tool measurements") + })?; + gnu.verify_binding(&boundary_fingerprint, plan_digest.sha256())?; + platform.with_gnu(gnu)? + } else { + platform + }; + if artifact.target == ReleaseTarget::Wasm32Wasip1 + && cfg!(all(target_os = "linux", target_arch = "x86_64")) + && fastly_tools.is_none() + { + return Err(recipe_error( + "Fastly completion lacks enforced host-tool measurements", + )); + } + let platform = if let Some(evidence) = fastly_tools { + evidence.verify_binding(&boundary_fingerprint, plan_digest.sha256())?; + platform.with_fastly_tools(*evidence)? + } else { + platform + }; + let captured = captured.with_evidence( + &selected_package_id, + origins, + native, + javascript, + platform, + )?; + self.check_source()?; + captured + } + #[cfg(test)] + WorkspaceSelection::Fixture => captured, + }) + } else { + None + }; + + if self.tools.authority() == Authority::Qualified + && !self.materials.derived_manifest_digests().is_empty() + { + return Err(Report::new(ReleaseError::Unqualified) + .attach("a build from caller-derived manifests cannot carry qualified authority")); + } + let recipe_definition = match self.workspace { + WorkspaceSelection::Approved(workspace) => Some(workspace.definition), + #[cfg(test)] + WorkspaceSelection::Fixture => None, + }; + RecipeCompleted::record_success(SuccessFacts { + asset: self.request.asset, + role: self.request.role, + commit: self.request.commit.clone(), + cargo_lock_sha256: self.materials.cargo_lock_sha256().to_owned(), + materials_digest: self.materials.digest().to_owned(), + js_inventory_sha256: self.js.inventory_sha256.clone(), + js_settings_sha256: self.js.settings_sha256.clone(), + js_tools_sha256: self.js.tools_sha256.clone(), + js_contributions: self.js.contributions, + rust_tools_digest: self.tools_digest.clone(), + boundary_fingerprint, + denial_receipt, + recipe_definition, + plan_digest, + ambient: self.ambient.clone(), + started_at_seconds: run.started_at_seconds, + elapsed: run.elapsed, + descendant_cleanup: run.cleanup, + stdout: run.stdout, + stderr_sha256: run.stderr_sha256, + artifact: copied, + output_dir: self.request.output.clone(), + normalized_manifest_digests: self.materials.normalized_manifest_digests().clone(), + derived_manifest_digests: self.materials.derived_manifest_digests().clone(), + cargo_observations, + }) + } +} + +/// Returns observed runtime requirements after accepting the copied final bytes. +/// +/// Wasm must be a core module that kept its debug sections. A Linux +/// executable must pass the reviewed glibc 2.34 ELF policy (`x86_64`, the +/// reviewed loader and libraries, no search path, no runner path) and keep +/// its DWARF sections. A macOS executable must pass the reviewed Mach-O +/// policy for its CPU (minimum macOS 13.0, reviewed loader and libraries, +/// linker ad-hoc signature on arm64, relative zero-time debug map), keep a +/// non-empty debug map, and record the SDK version measured from the bound +/// Apple developer tools. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Inspect`] when the final bytes are rejected, or +/// [`ReleaseError::Recipe`] for a Darwin artifact without Apple developer +/// tools or whose recorded SDK differs from the measured one. +fn inspect_copied( + target: ReleaseTarget, + bytes: &[u8], + apple: Option<&AppleDeveloperTools>, +) -> Result> { + let cpu = match target { + ReleaseTarget::Wasm32Wasip1 => { + let facts = inspect_wasm(bytes)?; + facts.require_debug_sections()?; + return Ok(ExternalRequirements::from_wasm(&facts)); + } + ReleaseTarget::X86_64UnknownLinuxGnu => { + let facts = inspect_elf(bytes, &ElfPolicy::linux_x86_64_gnu())?; + facts.require_debug_sections()?; + return Ok(ExternalRequirements::from_elf(&facts)); + } + ReleaseTarget::Aarch64AppleDarwin => MachoCpu::Arm64, + ReleaseTarget::X86_64AppleDarwin => MachoCpu::X86_64, + }; + let apple = + apple.ok_or_else(|| recipe_error("Darwin artifact has no Apple developer tools"))?; + let facts = inspect_macho(bytes, &MachoPolicy::macos(cpu))?; + facts.require_debug_map()?; + if facts.sdk() != apple.sdk_version() { + return Err(recipe_error(format!( + "Mach-O records SDK {}, but the bound Apple SDK is {}", + facts.sdk(), + apple.sdk_version() + ))); + } + Ok(ExternalRequirements::from_macho(&facts)) +} + +/// Digest of the whole retained JS root: contents, modes, names and aliases. +pub(super) fn retained_tree_digest(root: &Path) -> Result> { + tool_tree_digest(root, &|| Ok(())).map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("retained JS handoff cannot be digested") + }) +} + +/// Current Unix time in whole seconds. +fn unix_seconds() -> Result> { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .map_err(|error| recipe_error(format!("system clock is before the Unix epoch: {error}"))) +} diff --git a/crates/trusted-server-release/src/recipe/fastly_tools.rs b/crates/trusted-server-release/src/recipe/fastly_tools.rs new file mode 100644 index 000000000..04bfa4fe6 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/fastly_tools.rs @@ -0,0 +1,717 @@ +//! Bounded selected host files, without helper execution or closure claims. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use error_stack::Report; +use serde::{Deserialize, Serialize}; + +use super::gnu::MeasurementSandbox; +use super::{CargoPlan, SelectedRustTools, recipe_error}; +use crate::boundary::ConfinedCommand; +use crate::documents::{Role, canonical_json, hash_bounded_with_check, lower_hex, parse_json}; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessLimits}; + +const MODE: &str = "TRUSTED_SERVER_FASTLY_TOOL_MEASUREMENT"; +const PROFILE: &str = "fastly-linux-host-selected-files-v1"; +const HOST: &str = "x86_64-unknown-linux-gnu"; +const ROLES: [&str; 4] = [ + "host_compiler", + "host_archiver", + "configured_host_assembler", + "wasm_rust_linker", +]; + +/// Selects C tools for host build units only. +pub(super) fn environment() -> BTreeMap { + [("HOST_CC", "/usr/bin/cc"), ("HOST_AR", "/usr/bin/ar")] + .into_iter() + .map(|(name, value)| (name.to_owned(), value.to_owned())) + .collect() +} + +/// Selects host driver and target linker from the bound Rust root. +pub(super) fn linker_arguments(root: &str) -> Vec { + vec![ + "--config".to_owned(), + format!("target.\"{HOST}\".linker=\"/usr/bin/cc\""), + "--config".to_owned(), + format!("target.\"wasm32-wasip1\".linker=\"{root}/lib/rustlib/{HOST}/bin/rust-lld\""), + ] +} + +fn tool_root(path: &str) -> Result> { + let bin = path + .strip_suffix(":/usr/bin:/bin") + .ok_or_else(|| recipe_error("Fastly helper requires exact recipe PATH"))?; + let root = Path::new( + bin.strip_suffix("/bin") + .ok_or_else(|| recipe_error("Fastly PATH lacks tool bin"))?, + ); + if !root.starts_with("/boundary/tool") + || !root.is_absolute() + || root + .components() + .any(|part| !matches!(part, Component::RootDir | Component::Normal(_))) + || bin.contains(':') + || bin + .split('/') + .skip(1) + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(recipe_error( + "Fastly Rust root is outside bound readonly tools", + )); + } + Ok(root.to_owned()) +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileIdentity { + selected: PathBuf, + resolved: PathBuf, + sha256: String, + size: u64, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Measurement { + profile: String, + rust_root: PathBuf, + rust_host: String, + assembler_query: String, + files: BTreeMap, +} + +/// Files selected or configured by the bounded Linux Fastly recipe. +#[derive(Debug, Serialize)] +pub(crate) struct FastlyToolEvidence { + measurement: Measurement, + helper_sha256: String, + build_boundary_fingerprint: String, + measurement_boundary_fingerprint: String, + plan_sha256: String, + identity_scope: &'static str, + acquisition: &'static str, + full_closure_measured: bool, +} + +fn limits() -> ProcessLimits { + ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 64 * 1024, + stderr_max: 64 * 1024, + } +} + +fn query( + program: &Path, + argument: &str, + working: &Path, + path: &str, +) -> Result> { + let raw = BoundedCommand::new(program, working, limits())? + .arguments([OsString::from(argument)]) + .environment("PATH", path)? + .environment("LC_ALL", "C")? + .environment("TMPDIR", working)? + .run()? + .require_success()?; + String::from_utf8(raw).map_err(|error| recipe_error(error.to_string())) +} + +fn file_identity( + selected: &Path, + root: Option<&Path>, +) -> Result> { + let resolved = fs::canonicalize(selected).map_err(|error| recipe_error(error.to_string()))?; + let permitted = root.map_or_else( + || { + resolved.starts_with("/usr") + || resolved.starts_with("/bin") + || resolved.starts_with("/lib") + || resolved.starts_with("/lib64") + }, + |root| resolved.starts_with(root), + ); + if !permitted { + return Err(recipe_error( + "Fastly selected file escapes reviewed image or Rust root", + )); + } + let (sha256, size) = super::gnu::hash_image_file(&resolved)?; + Ok(FileIdentity { + selected: selected.to_owned(), + resolved, + sha256, + size, + }) +} + +/// Measures fixed selected files inside the denied Linux image. +/// +/// # Errors +/// +/// Rejects another helper mode, host, root, missing tool or failed query. +pub(super) fn helper_measurement() -> Result, Report> { + if !cfg!(all(target_os = "linux", target_arch = "x86_64")) + || std::env::var(MODE).as_deref() != Ok(PROFILE) + { + return Err(recipe_error( + "Fastly measurement requires fixed Linux x86_64 helper mode", + )); + } + let path = std::env::var("PATH").map_err(|error| recipe_error(error.to_string()))?; + let root = tool_root(&path)?; + let working = + PathBuf::from(std::env::var("TMPDIR").map_err(|error| recipe_error(error.to_string()))?); + if !working.starts_with("/boundary/writable") + || working + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(recipe_error("Fastly helper requires private TMPDIR")); + } + let version = query(&root.join("bin/rustc"), "-vV", &working, &path)?; + if version + .lines() + .filter(|line| line.starts_with("host: ")) + .collect::>() + != [format!("host: {HOST}")] + { + return Err(recipe_error("Fastly Rust host is not Linux x86_64")); + } + let assembler_query = query( + Path::new("/usr/bin/cc"), + "-print-prog-name=as", + &working, + &path, + )?; + let assembler = assembler_path(&assembler_query, &path)?; + let mut files = BTreeMap::new(); + for (role, selected) in [ + ("host_compiler", PathBuf::from("/usr/bin/cc")), + ("host_archiver", PathBuf::from("/usr/bin/ar")), + ("configured_host_assembler", assembler), + ] { + files.insert(role.to_owned(), file_identity(&selected, None)?); + } + files.insert( + "wasm_rust_linker".to_owned(), + file_identity( + &root.join(format!("lib/rustlib/{HOST}/bin/rust-lld")), + Some(&root), + )?, + ); + let measurement = Measurement { + profile: PROFILE.to_owned(), + rust_root: root, + rust_host: HOST.to_owned(), + assembler_query, + files, + }; + validate(&measurement, &measurement.rust_root)?; + canonical_json( + &serde_json::to_value(measurement).map_err(|error| recipe_error(error.to_string()))?, + Role::Metadata, + ) + .map_err(|error| error.change_context(ReleaseError::Recipe)) +} + +fn assembler_path(output: &str, path: &str) -> Result> { + let line = output + .strip_suffix('\n') + .filter(|line| !line.is_empty() && !line.chars().any(char::is_control)) + .ok_or_else(|| recipe_error("Fastly assembler query lacks one result line"))?; + if line == "as" { + // The fixed recipe PATH puts measured Rust tools before image tools. + return std::env::split_paths(path) + .map(|root| root.join("as")) + .find(|candidate| candidate.is_file()) + .ok_or_else(|| recipe_error("configured assembler missing from exact PATH")); + } + let selected = PathBuf::from(line); + if !selected.is_absolute() { + return Err(recipe_error("configured assembler is relative")); + } + Ok(selected) +} + +fn validate(value: &Measurement, root: &Path) -> Result<(), Report> { + if value.profile != PROFILE + || value.rust_root != root + || value.rust_host != HOST + || value.files.len() != ROLES.len() + || ROLES.iter().any(|role| !value.files.contains_key(*role)) + { + return Err(recipe_error("unsupported Fastly tool measurement")); + } + for file in value.files.values() { + if !file.selected.is_absolute() + || !file.resolved.is_absolute() + || !lower_hex(&file.sha256, 64) + || file + .resolved + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(recipe_error("invalid Fastly file identity")); + } + } + for (role, selected) in [ + ("host_compiler", PathBuf::from("/usr/bin/cc")), + ("host_archiver", PathBuf::from("/usr/bin/ar")), + ( + "wasm_rust_linker", + root.join(format!("lib/rustlib/{HOST}/bin/rust-lld")), + ), + ] { + if value.files[role].selected != selected { + return Err(recipe_error("Fastly selected tools differ from profile")); + } + } + if !value.files["wasm_rust_linker"].resolved.starts_with(root) { + return Err(recipe_error("Fastly Rust linker escaped measured root")); + } + let line = value + .assembler_query + .strip_suffix('\n') + .filter(|line| !line.is_empty() && !line.chars().any(char::is_control)) + .ok_or_else(|| recipe_error("invalid Fastly assembler query"))?; + let assembler = &value.files["configured_host_assembler"]; + if line == "as" { + if ![ + root.join("bin/as"), + PathBuf::from("/usr/bin/as"), + PathBuf::from("/bin/as"), + ] + .contains(&assembler.selected) + { + return Err(recipe_error("assembler differs from recipe PATH")); + } + } else if !Path::new(line).is_absolute() || assembler.selected != Path::new(line) { + return Err(recipe_error("assembler differs from compiler query")); + } + for role in [ + "host_compiler", + "host_archiver", + "configured_host_assembler", + ] { + let resolved = &value.files[role].resolved; + if !["/usr", "/bin", "/lib", "/lib64"] + .iter() + .any(|allowed| resolved.starts_with(allowed)) + { + return Err(recipe_error("Fastly host tool escaped image roots")); + } + } + Ok(()) +} + +impl FastlyToolEvidence { + /// Supplies fictional typed observations for pure projection tests. + #[cfg(test)] + pub(crate) fn projection_fixture() -> Self { + let mut fixture = validation_tests::fixture(); + fixture.plan_sha256 = "b".repeat(64); + fixture + } + + /// Borrows measured raw file roles, including the configured assembler. + pub(crate) fn measured_files(&self) -> impl Iterator { + self.measurement + .files + .iter() + .map(|(role, file)| (role.as_str(), file.sha256.as_str())) + } + /// Requires the completed build boundary and exact plan. + /// + /// # Errors + /// + /// Rejects another owner or malformed selected-file measurement. + pub(crate) fn verify_binding( + &self, + boundary: &[u8; 32], + plan: &str, + ) -> Result<(), Report> { + if self.build_boundary_fingerprint != hex::encode(boundary) + || self.plan_sha256 != plan + || !lower_hex(&self.measurement_boundary_fingerprint, 64) + || self.measurement_boundary_fingerprint == self.build_boundary_fingerprint + || self.full_closure_measured + || self.acquisition != "unqualified_candidate_measurement" + { + return Err(recipe_error("Fastly tools differ from completed owner")); + } + validate(&self.measurement, &self.measurement.rust_root) + } + /// Captures selected files using the sibling sandbox and its denial proof. + /// + /// # Errors + /// + /// Rejects another boundary, plan, helper or malformed measurement. + pub(super) fn measure( + tools: &SelectedRustTools, + plan: &CargoPlan, + build: &[u8; 32], + sandbox: &MeasurementSandbox, + ) -> Result> { + if sandbox.boundary.derived_from() != Some(*build) + || !sandbox.boundary.has_owned_descendant_cleanup() + { + return Err(recipe_error( + "Fastly tools require derived Docker measurement boundary", + )); + } + let path = plan + .environment + .get("PATH") + .ok_or_else(|| recipe_error("missing Fastly PATH"))?; + let root = tool_root(path)?; + if root != sandbox.boundary.execution_path(tools.root())? { + return Err(recipe_error("Fastly PATH root differs from bound tools")); + } + for (name, expected) in environment() { + if plan.environment.get(&name) != Some(&expected) { + return Err(recipe_error("Fastly host tools differ from profile")); + } + } + for pair in linker_arguments( + root.to_str() + .ok_or_else(|| recipe_error("non-UTF8 tool root"))?, + ) + .as_chunks::<2>() + .0 + { + if !plan.arguments.windows(2).any(|window| window == pair) { + return Err(recipe_error("Fastly linker differs from profile")); + } + } + let helper = tools.root().join("probe"); + let before = hash_bounded_with_check(&helper, 512 * 1024 * 1024, &|| Ok(())) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + let mut command = ConfinedCommand::new(&helper, &plan.working_directory, limits())? + .arguments([OsString::from("gnu-image-measurement")]) + .environment(MODE, PROFILE)?; + for name in ["HOME", "TMPDIR", "LC_ALL", "PATH"] { + command = command.environment( + name, + plan.environment + .get(name) + .ok_or_else(|| recipe_error("incomplete Fastly measurement environment"))?, + )?; + } + let raw = sandbox + .boundary + .run_confined(&command, &sandbox.proof)? + .require_success()?; + let measurement: Measurement = serde_json::from_value( + parse_json(&raw, Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?, + ) + .map_err(|error| recipe_error(error.to_string()))?; + validate(&measurement, &root)?; + let after = hash_bounded_with_check(&helper, 512 * 1024 * 1024, &|| Ok(())) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + if before.sha256 != after.sha256 { + return Err(recipe_error( + "Fastly staged helper changed during measurement", + )); + } + Ok(Self { + measurement, + helper_sha256: before.sha256, + build_boundary_fingerprint: hex::encode(build), + measurement_boundary_fingerprint: hex::encode(sandbox.boundary.execution_fingerprint()), + plan_sha256: plan.digest()?, + identity_scope: "selected_and_configured_files_in_read_only_image", + acquisition: "unqualified_candidate_measurement", + full_closure_measured: false, + }) + } + /// Requires stable selected files, helper, profile, boundaries and plan. + /// + /// # Errors + /// + /// Rejects any changed before/after receipt field. + pub(super) fn verify_after(&self, after: &Self) -> Result<(), Report> { + if serde_json::to_vec(self).map_err(|error| recipe_error(error.to_string()))? + != serde_json::to_vec(after).map_err(|error| recipe_error(error.to_string()))? + { + return Err(recipe_error("Fastly tools changed around execution")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fastly_host_tools_are_explicit_and_target_linker_is_bound() { + assert_eq!( + environment()["HOST_CC"], + "/usr/bin/cc", + "should select host driver" + ); + assert_eq!( + environment()["HOST_AR"], + "/usr/bin/ar", + "should select host archiver" + ); + let arguments = linker_arguments("/boundary/tool/0"); + assert!( + arguments.as_chunks::<2>().0.iter().any(|pair| pair + == &[ + "--config".to_owned(), + "target.\"x86_64-unknown-linux-gnu\".linker=\"/usr/bin/cc\"".to_owned() + ]), + "should select the explicit host Rust linker driver" + ); + assert!(arguments.iter().any(|argument| argument.contains("wasm32-wasip1\".linker=\"/boundary/tool/0/lib/rustlib/x86_64-unknown-linux-gnu/bin/rust-lld")), "should bind target linker to measured Rust root"); + } + + #[test] + fn fastly_tool_root_rejects_ambient_and_extra_path_entries() { + assert!( + tool_root("/usr/bin:/bin").is_err(), + "should reject ambient tools" + ); + assert!( + tool_root("/boundary/tool/0/bin:/usr/bin:/bin:/tmp").is_err(), + "should reject extra roots" + ); + assert_eq!( + tool_root("/boundary/tool/0/bin:/usr/bin:/bin").expect("should accept bound root"), + std::path::PathBuf::from("/boundary/tool/0"), + "should derive root from exact plan PATH" + ); + } +} + +#[cfg(test)] +mod validation_tests { + use super::*; + + pub(super) fn fixture() -> FastlyToolEvidence { + let root = PathBuf::from("/boundary/tool/0"); + let mut files = BTreeMap::new(); + for (role, selected) in [ + ("host_compiler", PathBuf::from("/usr/bin/cc")), + ("host_archiver", PathBuf::from("/usr/bin/ar")), + ("configured_host_assembler", PathBuf::from("/usr/bin/as")), + ( + "wasm_rust_linker", + root.join(format!("lib/rustlib/{HOST}/bin/rust-lld")), + ), + ] { + files.insert( + role.to_owned(), + FileIdentity { + resolved: selected.clone(), + selected, + sha256: "a".repeat(64), + size: 12, + }, + ); + } + FastlyToolEvidence { + measurement: Measurement { + profile: PROFILE.to_owned(), + rust_root: root, + rust_host: HOST.to_owned(), + assembler_query: "as\n".to_owned(), + files, + }, + helper_sha256: "b".repeat(64), + build_boundary_fingerprint: hex::encode([1; 32]), + measurement_boundary_fingerprint: hex::encode([2; 32]), + plan_sha256: "c".repeat(64), + identity_scope: "selected_and_configured_files_in_read_only_image", + acquisition: "unqualified_candidate_measurement", + full_closure_measured: false, + } + } + + #[test] + fn fastly_measurement_rejects_wrong_roles_hosts_roots_and_query_claims() { + let valid = fixture(); + let root = valid.measurement.rust_root.clone(); + assert!( + validate(&valid.measurement, &root).is_ok(), + "should accept fixed selected-file scope" + ); + let mut changed = fixture(); + changed.measurement.rust_host = "example-host".to_owned(); + assert!( + validate(&changed.measurement, &root).is_err(), + "should reject other Rust host" + ); + let mut changed = fixture(); + changed + .measurement + .files + .remove("configured_host_assembler"); + assert!( + validate(&changed.measurement, &root).is_err(), + "should reject missing role" + ); + let mut changed = fixture(); + changed + .measurement + .files + .get_mut("wasm_rust_linker") + .expect("should have linker") + .resolved = PathBuf::from("/usr/bin/ld"); + assert!( + validate(&changed.measurement, &root).is_err(), + "should reject escaped Rust linker" + ); + let mut changed = fixture(); + changed.measurement.assembler_query = "/usr/bin/other-as\n".to_owned(); + assert!( + validate(&changed.measurement, &root).is_err(), + "should reject query mismatch" + ); + let mut changed = fixture(); + changed + .measurement + .files + .get_mut("host_compiler") + .expect("should have compiler") + .sha256 = "z".repeat(64); + assert!( + validate(&changed.measurement, &root).is_err(), + "should reject malformed digest" + ); + } + + #[test] + fn fastly_measurement_rejects_raw_path_aliases() { + for path in [ + "/boundary/tool/0/../1/bin:/usr/bin:/bin", + "/boundary/tool/./0/bin:/usr/bin:/bin", + "/boundary//tool/0/bin:/usr/bin:/bin", + ] { + assert!( + tool_root(path).is_err(), + "should reject noncanonical tool root {path}" + ); + } + } + + #[test] + fn fastly_assembler_query_rejects_malformed_and_relative_results() { + for output in [ + "", + "as", + "as\nas\n", + "as\r\n", + "../as\n", + "relative/as\n", + "/usr/bin/as\0\n", + ] { + assert!( + assembler_path(output, "/boundary/tool/0/bin:/usr/bin:/bin").is_err(), + "should reject malformed compiler query {output:?}" + ); + } + assert_eq!( + assembler_path("/usr/bin/as\n", "/boundary/tool/0/bin:/usr/bin:/bin") + .expect("should parse one absolute assembler path"), + PathBuf::from("/usr/bin/as"), + "should preserve the path for subsequent independent hashing" + ); + } + + #[test] + fn fastly_receipt_rejects_another_owner_and_changed_measurement() { + let before = fixture(); + assert!( + before.verify_binding(&[1; 32], &"c".repeat(64)).is_ok(), + "should accept exact owner" + ); + assert!( + before.verify_binding(&[3; 32], &"c".repeat(64)).is_err(), + "should reject another build boundary" + ); + assert!( + before.verify_binding(&[1; 32], &"d".repeat(64)).is_err(), + "should reject another plan" + ); + assert!( + before.verify_after(&fixture()).is_ok(), + "should accept stable before and after" + ); + let mut after = fixture(); + after + .measurement + .files + .get_mut("host_compiler") + .expect("should have compiler") + .sha256 = "e".repeat(64); + assert!( + before.verify_after(&after).is_err(), + "should reject changed tool bytes" + ); + let mut after = fixture(); + after.helper_sha256 = "e".repeat(64); + assert!( + before.verify_after(&after).is_err(), + "should reject changed helper bytes" + ); + let mut after = fixture(); + after.measurement_boundary_fingerprint = after.build_boundary_fingerprint.clone(); + assert!( + after.verify_binding(&[1; 32], &"c".repeat(64)).is_err(), + "should require distinct measurement boundary" + ); + } +} + +#[cfg(test)] +mod file_tests { + use std::os::unix::fs::symlink; + + use sha2::{Digest as _, Sha256}; + + use super::*; + + #[test] + fn fastly_file_identity_hashes_bytes_and_rejects_escape_and_nonregular_files() { + let directory = tempfile::tempdir().expect("should allocate fictional tool root"); + let root = fs::canonicalize(directory.path()).expect("should resolve tool root"); + let selected = root.join("rust-lld"); + fs::write(&selected, b"fictional linker before").expect("should write selected file"); + let before = file_identity(&selected, Some(&root)).expect("should measure selected file"); + assert_eq!( + before.sha256, + hex::encode(Sha256::digest(b"fictional linker before")), + "should independently hash selected bytes" + ); + assert_eq!(before.size, 23, "should measure raw byte length"); + fs::write(&selected, b"fictional linker after").expect("should change selected bytes"); + let after = file_identity(&selected, Some(&root)).expect("should measure changed file"); + assert_ne!(before.sha256, after.sha256, "should bind changed raw bytes"); + assert!( + file_identity(&root, Some(&root)).is_err(), + "should reject directory as selected file" + ); + let outside = tempfile::tempdir().expect("should allocate fictional outside root"); + let outside_file = outside.path().join("other-linker"); + fs::write(&outside_file, b"fictional outside file").expect("should write outside file"); + let alias = root.join("escaping-alias"); + symlink(&outside_file, &alias).expect("should create fixture alias"); + assert!( + file_identity(&alias, Some(&root)).is_err(), + "should reject alias escaping selected Rust root" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe/final_paths.rs b/crates/trusted-server-release/src/recipe/final_paths.rs new file mode 100644 index 000000000..f90bcefd7 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/final_paths.rs @@ -0,0 +1,72 @@ +//! Final-byte path checks derived from the bound recipe rather than callers. + +use std::path::Path; + +use error_stack::Report; + +use super::paths::execution_text; +use super::remap::scan_remapped_bytes; +use super::{ExecutionPaths, PreparedRecipe, recipe_error}; +use crate::error::ReleaseError; + +/// Requires a bound host root to be strict absolute UTF-8 text. +fn host_text(path: &Path) -> Result> { + path.to_str() + .filter(|text| path.is_absolute() && !text.chars().any(char::is_control)) + .map(str::to_owned) + .ok_or_else(|| recipe_error("a final-path root is not plain absolute UTF-8")) +} + +impl PreparedRecipe { + /// Rejects every host and execution root and requires a fixed remapped prefix. + /// + /// The six mounted roots contribute both names. Output is host-only and + /// never translated because the build cannot access it. Whole material and + /// Cargo-state roots include vendor files, HOME, TMPDIR and Cargo home. A + /// Darwin build's Apple developer directory and detached SDK contribute + /// both names too. + /// + /// # Errors + /// + /// Returns an error for invalid root text, failed translation, a remaining + /// root or `/boundary/` prefix, or no fixed remapped prefix in the bytes. + pub(super) fn check_final_paths( + &self, + bytes: &[u8], + paths: &dyn ExecutionPaths, + ) -> Result<(), Report> { + let roots = [ + self.ambient.checkout_root(), + self.retained_root.as_path(), + self.request.materials.as_path(), + self.tools.root(), + self.request.cargo_state.as_path(), + self.request.target_dir.as_path(), + ]; + // Six host roots, six execution roots, the host output and a Darwin + // build's developer directory and detached SDK under both names stay + // below the existing scanner's fixed limit of sixty-four prefixes. + let mut forbidden = Vec::with_capacity(17); + for root in roots { + forbidden.push(host_text(root)?); + forbidden.push(execution_text(paths, "final-path root", root)?); + } + forbidden.push(host_text(&self.request.output)?); + if let Some(apple) = self.tools.apple() { + let developer = apple.developer_dir(); + forbidden.push(host_text(developer)?); + forbidden.push(execution_text( + paths, + "Apple developer directory", + developer, + )?); + if let Some(sdk) = apple.owned_sdk_root() { + forbidden.push(host_text(sdk)?); + forbidden.push(execution_text(paths, "owned Apple SDK", sdk)?); + } + } + let forbidden: Vec<&str> = forbidden.iter().map(String::as_str).collect(); + scan_remapped_bytes(bytes, &forbidden)?; + Ok(()) + } +} diff --git a/crates/trusted-server-release/src/recipe/fresh.rs b/crates/trusted-server-release/src/recipe/fresh.rs new file mode 100644 index 000000000..ccab97af4 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/fresh.rs @@ -0,0 +1,131 @@ +//! Lexical and on-disk checks for recipe request roots. + +use std::fs; +use std::io; +use std::path::Path; + +use error_stack::Report; + +use super::recipe_error; +use crate::error::ReleaseError; + +/// Requires an absolute UTF-8 path that is already in normal form. +/// +/// `Path::components` silently drops `.` and doubled or trailing separators, +/// so the text is compared with its rebuilt components instead. +pub(super) fn normal_absolute(label: &str, path: &Path) -> Result<(), Report> { + let normal = path.is_absolute() + && path.to_str().is_some() + && path + .components() + .collect::() + .as_os_str() + == path.as_os_str() + && !path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)); + if normal { + return Ok(()); + } + Err(recipe_error(format!( + "{label} must be an absolute normal path without dot segments" + ))) +} + +/// Requires `path` to resolve to itself, so no ancestor or leaf is a symlink. +fn unaliased(label: &str, path: &Path) -> Result<(), Report> { + match fs::canonicalize(path) { + Ok(resolved) if resolved == path => Ok(()), + Ok(_) => Err(recipe_error(format!( + "{label} is a symlink or traverses one" + ))), + Err(error) => Err(recipe_error(format!("{label} cannot be resolved: {error}"))), + } +} + +/// Requires an existing real directory that is not an alias. +pub(super) fn real_directory(label: &str, path: &Path) -> Result<(), Report> { + unaliased(label, path)?; + let metadata = fs::symlink_metadata(path) + .map_err(|error| recipe_error(format!("{label} cannot be inspected: {error}")))?; + if !metadata.is_dir() { + return Err(recipe_error(format!("{label} must be a directory"))); + } + Ok(()) +} + +/// Requires an existing real regular file that is not an alias. +pub(super) fn real_file(label: &str, path: &Path) -> Result<(), Report> { + unaliased(label, path)?; + let metadata = fs::symlink_metadata(path) + .map_err(|error| recipe_error(format!("{label} cannot be inspected: {error}")))?; + if !metadata.is_file() { + return Err(recipe_error(format!("{label} must be a regular file"))); + } + Ok(()) +} + +/// Requires a fresh root: a real directory with no entries. +pub(super) fn empty_directory(label: &str, path: &Path) -> Result<(), Report> { + real_directory(label, path)?; + let mut entries = fs::read_dir(path) + .map_err(|error| recipe_error(format!("{label} cannot be listed: {error}")))?; + if entries.next().is_some() { + return Err(recipe_error(format!( + "{label} already has entries and cannot be reused" + ))); + } + Ok(()) +} + +/// Requires a path that does not exist yet inside a real parent directory. +pub(super) fn absent_in_real_parent(label: &str, path: &Path) -> Result<(), Report> { + match fs::symlink_metadata(path) { + Ok(_) => { + return Err(recipe_error(format!( + "{label} already exists and cannot be reused" + ))); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(recipe_error(format!( + "{label} cannot be inspected: {error}" + ))); + } + } + let parent = path + .parent() + .ok_or_else(|| recipe_error(format!("{label} has no parent directory")))?; + real_directory(label, parent) +} + +/// Rejects any two labelled paths where one contains or equals the other. +pub(super) fn disjoint(paths: &[(&str, &Path)]) -> Result<(), Report> { + for (index, (first_label, first)) in paths.iter().enumerate() { + for (second_label, second) in paths.iter().skip(index + 1) { + if first.starts_with(second) || second.starts_with(first) { + return Err(recipe_error(format!( + "{first_label} and {second_label} overlap" + ))); + } + } + } + Ok(()) +} + +/// Requires the prepared materials root: a real directory holding exactly the +/// `vendor` directory, which is itself a real directory. +pub(super) fn prepared_materials(label: &str, path: &Path) -> Result<(), Report> { + real_directory(label, path)?; + let names = fs::read_dir(path) + .map_err(|error| recipe_error(format!("{label} cannot be listed: {error}")))? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::, _>>() + .map_err(|error| recipe_error(format!("{label} cannot be listed: {error}")))?; + if names != [std::ffi::OsString::from("vendor")] { + return Err(recipe_error(format!( + "{label} must hold exactly the prepared vendor directory" + ))); + } + real_directory("vendor directory", &path.join("vendor")) +} diff --git a/crates/trusted-server-release/src/recipe/gnu.rs b/crates/trusted-server-release/src/recipe/gnu.rs new file mode 100644 index 000000000..79df270d6 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/gnu.rs @@ -0,0 +1,612 @@ +//! Fixed GNU candidate profile; image identity supplies no release authority. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use error_stack::Report; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use super::{CargoPlan, SelectedRustTools, recipe_error}; +use crate::boundary::{Boundary, ConfinedCommand, DenialProof, ProbeProgram}; +use crate::documents::{Role, canonical_json, hash_bounded_with_check, lower_hex, parse_json}; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessLimits}; +use image::{image_path, installed_snapshot}; + +mod image; +#[cfg(test)] +mod tests; + +/// Explicit host and target C tools and fail-closed AWS-LC backend selection. +pub(super) fn environment() -> BTreeMap { + [ + ("CC_x86_64_unknown_linux_gnu", "/usr/bin/gcc"), + ("AR_x86_64_unknown_linux_gnu", "/usr/bin/ar"), + ("HOST_CC", "/usr/bin/gcc"), + ("HOST_AR", "/usr/bin/ar"), + ("AWS_LC_SYS_CMAKE_BUILDER", "0"), + ("AWS_LC_SYS_CMAKE_BUILDER_x86_64_unknown_linux_gnu", "0"), + ] + .into_iter() + .map(|(name, value)| (name.to_owned(), value.to_owned())) + .collect() +} + +/// Explicit Rust linker driver selected inside the image namespace. +/// +/// Rust may ask this driver to use rust-lld from the measured Rust tree. +/// Configured GCC leaf-linker candidates are not execution observations. +pub(super) fn linker_arguments() -> Vec { + vec![ + "--config".to_owned(), + "target.\"x86_64-unknown-linux-gnu\".linker=\"/usr/bin/gcc\"".to_owned(), + ] +} + +const PROFILE: &str = "gnu-cc-installed-image-v1"; +const HELPER_ARGUMENT: &str = "gnu-image-measurement"; +const MEASUREMENT_LIMIT: u64 = 64 * 1024; +const FIXED_SELECTED_FILES: [(&str, &str); 4] = [ + ("compiler", "/usr/bin/gcc"), + ("archiver", "/usr/bin/ar"), + ("gcc_linker_candidate", "/usr/bin/ld"), + ("gcc_assembler_candidate", "/usr/bin/as"), +]; +const SELECTED_ROLES: [&str; 9] = [ + "compiler", + "archiver", + "gcc_linker_candidate", + "gcc_assembler_candidate", + "configured_cc1", + "configured_collect2", + "configured_as", + "configured_ld", + "compiler_runtime", +]; +const COMPILER_QUERIES: [&str; 8] = [ + "-print-prog-name=cc1", + "-print-prog-name=collect2", + "-print-prog-name=as", + "-print-prog-name=ld", + "-print-libgcc-file-name", + "-print-file-name=include", + "-print-sysroot", + "-print-search-dirs", +]; + +/// Selected/configured installed-file facts, never a claim of executed helpers. +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Measurement { + profile: String, + installed_files_sha256: String, + /// Image files recorded by metadata only because the helper cannot read them. + content_unmeasured_files: Vec, + roots: Vec, + selected_files: BTreeMap, + compiler_queries: BTreeMap, + sysroot: String, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SelectedFile { + selected: PathBuf, + resolved: PathBuf, + sha256: String, + size: u64, +} + +/// Candidate GNU receipt retained inside the owning composition receipt. +/// +/// Installed bytes and configured helper paths do not authenticate acquisition +/// or prove that every installed helper executed. +#[derive(Debug, Serialize)] +pub(crate) struct GnuEvidence { + measurement: Measurement, + profile_sha256: String, + helper_sha256: String, + /// The boundary that ran the Cargo build this receipt belongs to. + build_boundary_fingerprint: String, + /// The sibling sandbox, derived from the build boundary without its target + /// root, that ran both measurements. + measurement_boundary_fingerprint: String, + plan_sha256: String, + identity_scope: &'static str, + acquisition: &'static str, + full_closure_measured: bool, +} + +fn limits() -> ProcessLimits { + ProcessLimits { + wall: Duration::from_secs(180), + stdout_max: MEASUREMENT_LIMIT, + stderr_max: MEASUREMENT_LIMIT, + } +} + +/// Limits of the measurement sandbox's denial probe. +fn probe_limits() -> ProcessLimits { + ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 4096, + stderr_max: 4096, + } +} + +/// Selects the staged tool-root release helper as the denial probe. +#[cfg(test)] +fn measurement_probe(tools: &SelectedRustTools) -> Result> { + ProbeProgram::from_staged_release_binary(&tools.root().join("probe")) +} + +/// No production selection runs a candidate build, so none selects a probe. +#[cfg(not(test))] +fn measurement_probe(_tools: &SelectedRustTools) -> Result> { + Err(Report::new(ReleaseError::Unqualified) + .attach("GNU measurement sandbox has no qualified denial probe")) +} + +/// The sandbox that runs both GNU measurements, separate from the build. +/// +/// It is a sibling of the build boundary without the recipe target root: +/// after the build that root legitimately holds Cargo's in-root hard links, +/// which the unchanged pre-run single-link scan rejects. Both measurements +/// run in this one instance, so their receipts stay byte-identical, and the +/// sandbox carries its own denial proof. +pub(super) struct MeasurementSandbox { + pub(super) boundary: Boundary, + pub(super) proof: DenialProof, +} + +impl MeasurementSandbox { + /// Derives the sibling of `build` without `target_dir` and proves denial + /// in it with the staged tool-root probe. + /// + /// # Errors + /// + /// Returns an error when the sibling cannot be derived (see + /// [`Boundary::sibling_without_writable`]), no probe is selectable, or + /// denial is not established. + pub(super) fn establish( + build: &Boundary, + tools: &SelectedRustTools, + target_dir: &Path, + ) -> Result> { + let boundary = build.sibling_without_writable(target_dir)?; + let probe = measurement_probe(tools)?; + let proof = boundary.establish_denial(&probe, probe_limits())?; + Ok(Self { boundary, proof }) + } +} + +/// Identity of the source-owned GNU selection and installed-root scope. +fn profile_digest() -> String { + let mut digest = Sha256::new(); + digest.update(PROFILE); + for root in image::ROOTS { + digest.update([0]); + digest.update(root); + } + for (name, value) in environment() { + digest.update([0]); + digest.update(name); + digest.update([0]); + digest.update(value); + } + for argument in linker_arguments() { + digest.update([0]); + digest.update(argument); + } + hex::encode(digest.finalize()) +} + +fn file_identity(selected: &Path) -> Result> { + file_identity_at(Path::new("/"), selected) +} + +fn file_identity_at(root: &Path, selected: &Path) -> Result> { + let root = fs::canonicalize(root).map_err(|error| recipe_error(error.to_string()))?; + let relative = selected + .strip_prefix("/") + .map_err(|error| recipe_error(error.to_string()))?; + let actual = fs::canonicalize(root.join(relative)) + .map_err(|error| recipe_error(format!("cannot resolve selected GNU file: {error}")))?; + let resolved = Path::new("/").join( + actual + .strip_prefix(&root) + .map_err(|error| recipe_error(error.to_string()))?, + ); + image_path(&resolved)?; + if !fs::metadata(&actual) + .map_err(|error| recipe_error(error.to_string()))? + .is_file() + { + return Err(recipe_error("selected GNU tool is not a regular file")); + } + let (sha256, size) = image::image_file_hash(&actual)?; + Ok(SelectedFile { + selected: selected.to_owned(), + resolved, + sha256, + size, + }) +} + +fn compiler_query(argument: &str, working: &Path) -> Result> { + let output = BoundedCommand::new(Path::new("/usr/bin/gcc"), working, limits())? + .arguments([OsString::from(argument)]) + .environment( + "PATH", + std::env::var_os("PATH") + .ok_or_else(|| recipe_error("GNU helper requires the exact recipe PATH"))?, + )? + .environment("LC_ALL", "C")? + .environment("TMPDIR", working)? + .run()? + .require_success()?; + String::from_utf8(output) + .map_err(|error| recipe_error(format!("GNU query is not UTF-8: {error}"))) +} + +/// Writes the fixed installed-image measurement in the selected Linux namespace. +/// +/// # Errors +/// +/// Rejects non-Linux hosts, missing/escaping tools/resources, failed bounded +/// compiler queries, unsupported sysroots and oversized installed inventories. +/// This private helper supplies no selection or qualification capability. +pub(crate) fn helper_measurement() -> Result, Report> { + if std::env::var_os("TRUSTED_SERVER_FASTLY_TOOL_MEASUREMENT").is_some() { + return super::fastly_tools::helper_measurement(); + } + if !cfg!(target_os = "linux") { + return Err(recipe_error("GNU measurement helper requires Linux")); + } + let working = std::env::var_os("TMPDIR") + .map(PathBuf::from) + .ok_or_else(|| recipe_error("GNU helper requires private TMPDIR"))?; + if !working.is_absolute() || !working.starts_with("/boundary/writable") { + return Err(recipe_error( + "GNU helper TMPDIR is outside the private writable mount", + )); + } + let mut selected_files = BTreeMap::new(); + for (role, path) in FIXED_SELECTED_FILES { + selected_files.insert(role.to_owned(), file_identity(Path::new(path))?); + } + let mut compiler_queries = BTreeMap::new(); + for name in ["cc1", "collect2", "as", "ld"] { + let query = format!("-print-prog-name={name}"); + let output = compiler_query(&query, &working)?; + let text = output + .strip_suffix('\n') + .ok_or_else(|| recipe_error("GNU helper query has no final newline"))?; + let selected = if text == name && matches!(name, "as" | "ld") { + let path = std::env::var_os("PATH") + .ok_or_else(|| recipe_error("GNU helper requires recipe PATH"))?; + std::env::split_paths(&path) + .map(|root| root.join(name)) + .find(|candidate| candidate.is_file()) + .ok_or_else(|| recipe_error("GNU helper is absent from recipe PATH"))? + } else { + PathBuf::from(text) + }; + selected_files.insert(format!("configured_{name}"), file_identity(&selected)?); + compiler_queries.insert(query, output); + } + let query = "-print-libgcc-file-name"; + let output = compiler_query(query, &working)?; + selected_files.insert( + "compiler_runtime".to_owned(), + file_identity(Path::new(output.trim_end_matches('\n')))?, + ); + compiler_queries.insert(query.to_owned(), output); + let include = compiler_query("-print-file-name=include", &working)?; + let include_path = fs::canonicalize(include.trim_end_matches('\n')) + .map_err(|error| recipe_error(error.to_string()))?; + image_path(&include_path)?; + if !include_path.is_dir() { + return Err(recipe_error( + "GNU compiler include resource is not a directory", + )); + } + compiler_queries.insert("-print-file-name=include".to_owned(), include); + let sysroot = compiler_query("-print-sysroot", &working)?; + check_image_sysroot(&sysroot)?; + compiler_queries.insert("-print-sysroot".to_owned(), sysroot); + compiler_queries.insert( + "-print-search-dirs".to_owned(), + compiler_query("-print-search-dirs", &working)?, + ); + let installed = installed_snapshot(Path::new("/"))?; + let measurement = Measurement { + profile: PROFILE.to_owned(), + installed_files_sha256: installed.sha256, + content_unmeasured_files: installed.content_unmeasured, + roots: image::ROOTS.iter().map(|root| (*root).to_owned()).collect(), + selected_files, + compiler_queries, + sysroot: "/".to_owned(), + }; + canonical_json( + &serde_json::to_value(measurement).map_err(|error| recipe_error(error.to_string()))?, + Role::Metadata, + ) + .map_err(|error| error.change_context(ReleaseError::Recipe)) +} + +/// Requires a strictly sorted subset of the reviewed unreadable image paths. +fn valid_content_unmeasured(files: &[String]) -> bool { + files.windows(2).all(|pair| pair[0] < pair[1]) + && files + .iter() + .all(|file| image::REVIEWED_UNREADABLE.contains(&file.as_str())) +} + +fn check_image_sysroot(value: &str) -> Result<(), Report> { + // GCC prints no bytes when no alternate target sysroot was configured. + if !matches!(value, "" | "\n" | "/\n") { + let observed = value.chars().take(256).collect::(); + return Err(recipe_error(format!( + "GNU profile requires the image-root sysroot; compiler reported {observed:?}" + ))); + } + Ok(()) +} + +fn validate_measurement(measurement: &Measurement) -> Result<(), Report> { + if measurement.profile != PROFILE + || !lower_hex(&measurement.installed_files_sha256, 64) + || !valid_content_unmeasured(&measurement.content_unmeasured_files) + || measurement.roots != image::ROOTS + || measurement.sysroot != "/" + || measurement.selected_files.len() != SELECTED_ROLES.len() + || SELECTED_ROLES + .iter() + .any(|role| !measurement.selected_files.contains_key(*role)) + || measurement.compiler_queries.len() != COMPILER_QUERIES.len() + || COMPILER_QUERIES + .iter() + .any(|query| !measurement.compiler_queries.contains_key(*query)) + { + return Err(recipe_error( + "GNU helper returned an unsupported measurement", + )); + } + for file in measurement.selected_files.values() { + if !file.selected.is_absolute() { + return Err(recipe_error("GNU query returned a relative file path")); + } + image_path(&file.resolved)?; + if !lower_hex(&file.sha256, 64) { + return Err(recipe_error("invalid GNU file identity")); + } + } + check_image_sysroot(&measurement.compiler_queries["-print-sysroot"])?; + for (role, path) in FIXED_SELECTED_FILES { + if measurement.selected_files[role].selected != Path::new(path) { + return Err(recipe_error( + "GNU selected driver differs from the fixed profile", + )); + } + } + for (role, query, bare_name) in [ + ("configured_cc1", "-print-prog-name=cc1", None), + ("configured_collect2", "-print-prog-name=collect2", None), + ("configured_as", "-print-prog-name=as", Some("as")), + ("configured_ld", "-print-prog-name=ld", Some("ld")), + ("compiler_runtime", "-print-libgcc-file-name", None), + ] { + let output = measurement.compiler_queries[query] + .strip_suffix('\n') + .filter(|value| !value.is_empty() && !value.chars().any(char::is_control)) + .ok_or_else(|| recipe_error("GNU path query lacks one bounded result line"))?; + let file = &measurement.selected_files[role]; + let selected = &file.selected; + let matches = if let Some(name) = bare_name.filter(|name| *name == output) { + let candidate = &measurement.selected_files[if name == "as" { + "gcc_assembler_candidate" + } else { + "gcc_linker_candidate" + }]; + // The fixed image candidate precedes /bin in the selected PATH. + // An alternate spelling must identify the same measured image file. + (selected == Path::new(&format!("/usr/bin/{name}")) + || selected == Path::new(&format!("/bin/{name}"))) + && file.resolved == candidate.resolved + && file.sha256 == candidate.sha256 + && file.size == candidate.size + } else { + Path::new(output).is_absolute() && selected == Path::new(output) + }; + if !matches { + return Err(recipe_error( + "GNU selected file differs from its compiler query", + )); + } + } + Ok(()) +} + +impl GnuEvidence { + /// Returns whether installed-file measurements lack a complete tool closure. + pub(crate) fn tool_closure_unmeasured(&self) -> bool { + !self.full_closure_measured + } + /// Returns whether acquisition is recorded as an unqualified candidate measurement. + pub(crate) fn original_acquisition_unverified(&self) -> bool { + self.acquisition == "unqualified_candidate_measurement" + } + /// Supplies fictional typed observations for projection tests only. + #[cfg(test)] + pub(crate) fn projection_fixture() -> Self { + tests::projection_fixture() + } + /// Supplies an unreviewed fictional file role for fail-closed adapter tests. + #[cfg(test)] + pub(crate) fn projection_fixture_with_unknown_file_role() -> Self { + let mut fixture = Self::projection_fixture(); + let file = fixture + .measurement + .selected_files + .remove("compiler_runtime") + .expect("should have fictional runtime archive"); + fixture + .measurement + .selected_files + .insert("unreviewed_compiler_resource".to_owned(), file); + fixture + } + /// Returns the conservative installed-image tree identity. + pub(crate) fn installed_files_sha256(&self) -> &str { + &self.measurement.installed_files_sha256 + } + /// Lists image files covered by metadata only, never by content. + pub(crate) fn content_unmeasured_files(&self) -> &[String] { + &self.measurement.content_unmeasured_files + } + /// Borrows selected/configured raw-file identities without private paths. + pub(crate) fn measured_files(&self) -> impl Iterator { + self.measurement + .selected_files + .iter() + .map(|(role, file)| (role.as_str(), file.sha256.as_str())) + } + /// Requires this receipt's build boundary and exact plan at completion + /// sealing. + /// + /// `build_boundary` is the boundary that ran the Cargo build. The + /// measurement sandbox was required to be derived from that boundary when + /// the receipt was measured; here its recorded fingerprint must be a + /// distinct well-formed instance fingerprint. This relies on in-process + /// construction through [`GnuEvidence::measure`]: a receipt read back from + /// serialized data cannot prove that derivation. + /// + /// # Errors + /// + /// Rejects another build boundary, invocation or source-owned GNU + /// profile, or a malformed measurement sandbox fingerprint. + pub(crate) fn verify_binding( + &self, + build_boundary: &[u8; 32], + plan: &str, + ) -> Result<(), Report> { + let build = hex::encode(build_boundary); + if self.build_boundary_fingerprint != build + || self.plan_sha256 != plan + || self.profile_sha256 != profile_digest() + { + return Err(recipe_error( + "GNU receipt differs from the completed boundary or plan", + )); + } + if !lower_hex(&self.measurement_boundary_fingerprint, 64) + || self.measurement_boundary_fingerprint == build + { + return Err(recipe_error( + "GNU receipt lacks a separate measurement sandbox fingerprint", + )); + } + Ok(()) + } + /// Measures through the staged helper in the sandbox derived from the + /// build boundary `build_boundary`, under that sandbox's denial proof. + /// + /// # Errors + /// + /// Rejects a sandbox not derived from `build_boundary`, another boundary + /// type, missing helper, changed profile/plan or malformed, unsupported or + /// failed in-image measurements. + pub(super) fn measure( + tools: &SelectedRustTools, + plan: &CargoPlan, + build_boundary: &[u8; 32], + sandbox: &MeasurementSandbox, + ) -> Result> { + let boundary = &sandbox.boundary; + if boundary.derived_from() != Some(*build_boundary) { + return Err(recipe_error( + "GNU measurement sandbox was not derived from the build boundary", + )); + } + if !boundary.has_owned_descendant_cleanup() { + return Err(recipe_error( + "GNU profile requires the selected Docker boundary", + )); + } + for (name, expected) in environment() { + if plan.environment.get(&name) != Some(&expected) { + return Err(recipe_error("GNU plan differs from fixed native profile")); + } + } + let linker = linker_arguments(); + if !plan + .arguments + .windows(2) + .any(|arguments| arguments == linker) + { + return Err(recipe_error( + "GNU plan does not select the reviewed linker driver", + )); + } + let helper = tools.root().join("probe"); + let helper_hash = hash_bounded_with_check(&helper, 512 * 1024 * 1024, &|| Ok(())) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + let mut command = ConfinedCommand::new(&helper, &plan.working_directory, limits())? + .arguments([OsString::from(HELPER_ARGUMENT)]); + for name in ["HOME", "TMPDIR", "LC_ALL", "PATH"] { + command = command.environment( + name, + plan.environment + .get(name) + .ok_or_else(|| recipe_error("GNU measurement environment is incomplete"))?, + )?; + } + let raw = boundary + .run_confined(&command, &sandbox.proof)? + .require_success()?; + let value = parse_json(&raw, Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + let measurement: Measurement = serde_json::from_value(value) + .map_err(|error| recipe_error(format!("invalid GNU measurement: {error}")))?; + validate_measurement(&measurement)?; + Ok(Self { + measurement, + profile_sha256: profile_digest(), + helper_sha256: helper_hash.sha256, + build_boundary_fingerprint: hex::encode(build_boundary), + measurement_boundary_fingerprint: hex::encode(boundary.execution_fingerprint()), + plan_sha256: plan.digest()?, + identity_scope: "selected_and_configured_files_in_read_only_image", + acquisition: "unqualified_candidate_measurement", + full_closure_measured: false, + }) + } + + /// Requires identical before/after in-image measurements for this invocation. + /// + /// # Errors + /// + /// Rejects changed installed bytes, helper, boundary, profile or Cargo plan. + pub(super) fn verify_after(&self, after: &Self) -> Result<(), Report> { + let encode = |value: &Self| { + serde_json::to_vec(value).map_err(|error| recipe_error(error.to_string())) + }; + if encode(self)? != encode(after)? { + return Err(recipe_error( + "GNU image inputs or invocation changed around execution", + )); + } + Ok(()) + } +} + +/// Hashes a resolved image regular file without following its final alias. +pub(super) fn hash_image_file(path: &Path) -> Result<(String, u64), Report> { + image::image_file_hash(path) +} diff --git a/crates/trusted-server-release/src/recipe/gnu/image.rs b/crates/trusted-server-release/src/recipe/gnu/image.rs new file mode 100644 index 000000000..bb54978f0 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/gnu/image.rs @@ -0,0 +1,308 @@ +//! Bounded installed-image file identity, separate from owned tool-tree policy. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Read as _; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::super::recipe_error; +use crate::error::ReleaseError; + +/// Image paths conservatively covering installed native tools and platform files. +/// +/// Alias targets are recorded, never traversed. This is not execution tracing +/// and does not imply that files outside these trees are unavailable. +pub(super) const ROOTS: [&str; 10] = [ + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib64", + "/etc/alternatives", + "/etc/ld.so.conf", + "/etc/ld.so.conf.d", + "/etc/ld.so.cache", + "/etc/gcc", +]; +const MAX_ENTRIES: usize = 250_000; +const MAX_FILE: u64 = 512 * 1024 * 1024; +const MAX_BYTES: u64 = 8 * 1024 * 1024 * 1024; +/// Reviewed root-only privileged helpers in the ordinary UBI 9 builder image. +/// +/// Observed in `registry.access.redhat.com/ubi9/ubi:9.6` with gcc, glibc-devel +/// and git-core: the setgid `utempter`, the PAM `unix_update` and the setuid +/// `userhelper`. None is a compiler, linker or build helper. Their content is +/// not hashed; it is bound only through the image identity itself. +pub(super) const REVIEWED_UNREADABLE: [&str; 3] = [ + "/usr/libexec/utempter/utempter", + "/usr/sbin/unix_update", + "/usr/sbin/userhelper", +]; + +/// Which unreadable image files may be recorded by metadata only. +pub(super) struct UnreadablePolicy<'a> { + /// Exact absolute image paths, sorted. + pub(super) paths: &'a [&'a str], + /// The owner every such file must have. + pub(super) owner_uid: u32, +} + +/// The production policy: the reviewed paths, owned by root. +pub(super) const PRODUCTION_UNREADABLE: UnreadablePolicy<'static> = UnreadablePolicy { + paths: &REVIEWED_UNREADABLE, + owner_uid: 0, +}; + +/// The installed-image identity and the files whose content it could not read. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct InstalledSnapshot { + /// Digest of every recorded name and fact. + pub(super) sha256: String, + /// Sorted absolute image paths recorded by metadata only, never by content. + pub(super) content_unmeasured: Vec, +} + +/// Opens one image file without following a final alias. +fn open_image_file(path: &Path) -> std::io::Result { + fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) +} + +/// Hashes one held no-follow regular file from the read-only image namespace. +/// +/// Unlike host document handling, image hardlinks are permitted. Only the +/// confined GNU and Fastly helpers use this policy to measure candidate files. +pub(super) fn image_file_hash(path: &Path) -> Result<(String, u64), Report> { + let file = open_image_file(path).map_err(|error| { + recipe_error(format!( + "cannot open image file {}: {error}", + path.display() + )) + })?; + hash_open_file(file) +} + +/// Hashes an opened image file, requiring unchanged identity while reading. +fn hash_open_file(mut file: fs::File) -> Result<(String, u64), Report> { + let before = file + .metadata() + .map_err(|error| recipe_error(error.to_string()))?; + if !before.is_file() || before.len() > MAX_FILE { + return Err(recipe_error("image input is not a bounded regular file")); + } + let mut digest = Sha256::new(); + let mut bytes = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let length = file + .read(&mut buffer) + .map_err(|error| recipe_error(error.to_string()))?; + if length == 0 { + break; + } + bytes += length as u64; + if bytes > MAX_FILE { + return Err(recipe_error("image file exceeds byte limit")); + } + digest.update(&buffer[..length]); + } + let after = file + .metadata() + .map_err(|error| recipe_error(error.to_string()))?; + let facts = |metadata: &fs::Metadata| { + ( + metadata.dev(), + metadata.ino(), + metadata.mode(), + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) + }; + if facts(&before) != facts(&after) || bytes != before.len() { + return Err(recipe_error("image file changed during measurement")); + } + Ok((hex::encode(digest.finalize()), bytes)) +} + +/// Requires an absolute normal selected path within the measured image trees. +pub(super) fn image_path(path: &Path) -> Result<(), Report> { + let text = path + .to_str() + .ok_or_else(|| recipe_error("GNU path is not UTF-8"))?; + if !path.is_absolute() + || text.split('/').any(|part| matches!(part, "." | "..")) + || !ROOTS.iter().any(|root| path.starts_with(root)) + { + return Err(recipe_error( + "GNU tool or resource escapes measured image paths", + )); + } + Ok(()) +} + +/// Measures installed file names, kinds, modes, content and alias targets. +/// +/// Hard links are acceptable here because the owning Docker boundary supplies +/// a read-only image filesystem; this function must never authorize host tools. +/// Missing optional roots are explicit facts. Docker-generated host/network +/// files and pseudo filesystems are outside the listed scope. +/// +/// Only the exact reviewed paths in [`PRODUCTION_UNREADABLE`] may be recorded +/// by mode, owner and size instead of content when the unprivileged helper +/// cannot open them; they are listed in +/// [`InstalledSnapshot::content_unmeasured`]. Any other unreadable file, or a +/// reviewed path with another owner, a group/other write bit or other-read +/// permission, is rejected. Execute-only files outside that list therefore +/// fail closed. Selected build tools are hashed separately and never take +/// this path. +pub(super) fn installed_snapshot(root: &Path) -> Result> { + installed_snapshot_with(root, &PRODUCTION_UNREADABLE) +} + +/// [`installed_snapshot`] with an explicit unreadable-file policy. +pub(super) fn installed_snapshot_with( + root: &Path, + unreadable: &UnreadablePolicy<'_>, +) -> Result> { + let mut pending: Vec = ROOTS.iter().map(|path| root.join(&path[1..])).collect(); + let mut facts = BTreeMap::new(); + let mut content_unmeasured = Vec::new(); + let mut bytes = 0_u64; + while let Some(path) = pending.pop() { + let name = path + .strip_prefix(root) + .map_err(|error| recipe_error(error.to_string()))? + .to_str() + .ok_or_else(|| recipe_error("image path is not UTF-8"))? + .to_owned(); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(recipe_error(format!( + "cannot inspect image file {name}: {error}" + ))); + } + }; + let fact = if let Some(metadata) = metadata { + let mode = metadata.permissions().mode() & 0o7777; + if metadata.is_dir() { + for entry in fs::read_dir(&path).map_err(|error| recipe_error(error.to_string()))? { + pending.push( + entry + .map_err(|error| recipe_error(error.to_string()))? + .path(), + ); + if pending.len() + facts.len() > MAX_ENTRIES { + return Err(recipe_error("GNU image inventory exceeds entry limit")); + } + } + format!("directory:{mode}") + } else if metadata.is_file() { + match open_image_file(&path) { + Ok(file) => { + let (sha256, size) = hash_open_file(file)?; + bytes = bytes + .checked_add(size) + .filter(|bytes| *bytes <= MAX_BYTES) + .ok_or_else(|| { + recipe_error("GNU image inventory exceeds byte limit") + })?; + format!("file:{mode}:{size}:{sha256}") + } + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + let absolute = format!("/{name}"); + check_reviewed_unreadable(&absolute, &path, &metadata, unreadable)?; + content_unmeasured.push(absolute); + format!( + "unmeasured-file:{mode}:{}:{}:{}", + metadata.uid(), + metadata.gid(), + metadata.len() + ) + } + Err(error) => { + return Err(recipe_error(format!( + "cannot open image file /{name}: {error}" + ))); + } + } + } else if metadata.file_type().is_symlink() { + let target = + fs::read_link(&path).map_err(|error| recipe_error(error.to_string()))?; + format!( + "alias:{mode}:{}", + target + .to_str() + .ok_or_else(|| recipe_error("image alias is not UTF-8"))? + ) + } else { + return Err(recipe_error("GNU image inventory contains a special file")); + } + } else { + "missing".to_owned() + }; + facts.insert(name, fact); + if facts.len() > MAX_ENTRIES { + return Err(recipe_error("GNU image inventory exceeds entry limit")); + } + } + let mut digest = Sha256::new(); + digest.update(b"trusted-server-gnu-installed-image-v2\n"); + for (name, fact) in facts { + for value in [name, fact] { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value.as_bytes()); + } + } + content_unmeasured.sort(); + Ok(InstalledSnapshot { + sha256: hex::encode(digest.finalize()), + content_unmeasured, + }) +} + +/// Accepts a permission-denied file only at a reviewed path with expected +/// ownership and permissions, unchanged since it was first inspected. +fn check_reviewed_unreadable( + absolute: &str, + path: &Path, + metadata: &fs::Metadata, + unreadable: &UnreadablePolicy<'_>, +) -> Result<(), Report> { + let mode = metadata.mode(); + if !unreadable.paths.contains(&absolute) + || metadata.uid() != unreadable.owner_uid + || mode & 0o022 != 0 + || mode & 0o004 != 0 + { + return Err(recipe_error(format!( + "image file {absolute} cannot be read and is not a reviewed privileged helper" + ))); + } + let after = fs::symlink_metadata(path).map_err(|error| recipe_error(error.to_string()))?; + let facts = |metadata: &fs::Metadata| { + ( + metadata.mode(), + metadata.uid(), + metadata.gid(), + metadata.len(), + metadata.is_file(), + ) + }; + if facts(metadata) != facts(&after) { + return Err(recipe_error(format!( + "image file {absolute} changed during measurement" + ))); + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/recipe/gnu/tests.rs b/crates/trusted-server-release/src/recipe/gnu/tests.rs new file mode 100644 index 000000000..2e5de9259 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/gnu/tests.rs @@ -0,0 +1,629 @@ +use std::fs; +use std::os::unix::fs::{PermissionsExt as _, symlink}; + +use super::*; + +/// Writes an image file with the given permission bits. +fn image_file(root: &std::path::Path, relative: &str, mode: u32) { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("should have a parent")) + .expect("should create image directory"); + fs::write(&path, b"privileged").expect("should write image file"); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).expect("should set mode"); +} + +/// Reports whether mode bits deny this process; root bypasses them. +fn mode_bits_apply() -> bool { + // SAFETY: `geteuid` has no preconditions. + unsafe { libc::geteuid() != 0 } +} + +#[test] +fn only_reviewed_unreadable_image_files_are_recorded_by_metadata() { + if !mode_bits_apply() { + return; + } + let temporary = tempfile::tempdir().expect("should create image fixture"); + let root = temporary.path(); + // SAFETY: `geteuid` has no preconditions. + let owner_uid = unsafe { libc::geteuid() }; + let paths = ["/usr/sbin/userhelper"]; + let policy = image::UnreadablePolicy { + paths: &paths, + owner_uid, + }; + image_file(root, "usr/sbin/userhelper", 0o111); + let first = image::installed_snapshot_with(root, &policy) + .expect("should record the reviewed helper by metadata"); + assert_eq!( + first.content_unmeasured, + vec!["/usr/sbin/userhelper".to_owned()], + "should name the file whose content was not measured" + ); + fs::set_permissions( + root.join("usr/sbin/userhelper"), + fs::Permissions::from_mode(0o100), + ) + .expect("should change helper mode"); + assert_ne!( + first.sha256, + image::installed_snapshot_with(root, &policy) + .expect("should remeasure") + .sha256, + "should bind the unmeasured file's mode" + ); + fs::set_permissions( + root.join("usr/sbin/userhelper"), + fs::Permissions::from_mode(0o121), + ) + .expect("should add a group write bit"); + assert!( + image::installed_snapshot_with(root, &policy).is_err(), + "should reject a group-writable reviewed helper" + ); + fs::set_permissions( + root.join("usr/sbin/userhelper"), + fs::Permissions::from_mode(0o111), + ) + .expect("should restore helper mode"); + let other_owner = image::UnreadablePolicy { + paths: &paths, + owner_uid: owner_uid.wrapping_add(1), + }; + assert!( + image::installed_snapshot_with(root, &other_owner).is_err(), + "should reject a reviewed path with another owner" + ); + image_file(root, "usr/libexec/gcc/example/lto-wrapper", 0o111); + let rejected = image::installed_snapshot_with(root, &policy) + .expect_err("should reject an unreviewed execute-only helper"); + assert!( + format!("{rejected:?}").contains("/usr/libexec/gcc/example/lto-wrapper"), + "should name the rejected file: {rejected:?}" + ); +} + +#[test] +fn production_policy_reviews_only_the_known_root_owned_helpers() { + assert_eq!( + image::PRODUCTION_UNREADABLE.paths, + image::REVIEWED_UNREADABLE.as_slice(), + "should use exactly the reviewed paths" + ); + assert_eq!( + image::PRODUCTION_UNREADABLE.owner_uid, + 0, + "should require root ownership" + ); + assert!( + image::REVIEWED_UNREADABLE + .windows(2) + .all(|pair| pair[0] < pair[1]), + "should keep the reviewed paths sorted" + ); +} + +#[test] +fn content_unmeasured_lists_must_be_sorted_reviewed_paths() { + assert!( + valid_content_unmeasured(&[]), + "should accept no unmeasured files" + ); + assert!( + valid_content_unmeasured(&[ + "/usr/libexec/utempter/utempter".to_owned(), + "/usr/sbin/unix_update".to_owned(), + "/usr/sbin/userhelper".to_owned(), + ]), + "should accept the sorted reviewed paths" + ); + for files in [ + vec![ + "/usr/sbin/userhelper".to_owned(), + "/usr/sbin/unix_update".to_owned(), + ], + vec![ + "/usr/sbin/userhelper".to_owned(), + "/usr/sbin/userhelper".to_owned(), + ], + vec!["/usr/libexec/gcc/example/lto-wrapper".to_owned()], + vec!["/usr//sbin/userhelper".to_owned()], + ] { + assert!( + !valid_content_unmeasured(&files), + "should reject unsorted, duplicate or unreviewed lists {files:?}" + ); + } +} + +#[test] +fn selected_tools_must_be_readable_even_when_executable() { + if !mode_bits_apply() { + return; + } + let temporary = tempfile::tempdir().expect("should create image fixture"); + let root = temporary.path(); + image_file(root, "usr/bin/gcc", 0o111); + assert!( + file_identity_at(root, std::path::Path::new("/usr/bin/gcc")).is_err(), + "should refuse to select an execute-only compiler without hashing it" + ); +} + +#[test] +fn compiler_default_sysroot_can_have_no_output() { + for output in ["", "\n", "/\n"] { + assert!( + check_image_sysroot(output).is_ok(), + "should accept GCC's default or explicit image-root sysroot response" + ); + } + for output in ["/other\n", "relative\n", " /\n", "/\r\n", "\n\n"] { + assert!( + check_image_sysroot(output).is_err(), + "should reject an unreviewed sysroot response {output:?}" + ); + } +} + +pub(super) fn projection_fixture() -> GnuEvidence { + GnuEvidence { + measurement: Measurement { + profile: PROFILE.to_owned(), + installed_files_sha256: "a".repeat(64), + content_unmeasured_files: Vec::new(), + roots: image::ROOTS.iter().map(|root| (*root).to_owned()).collect(), + selected_files: [ + ("compiler", "/usr/bin/gcc"), + ("archiver", "/usr/bin/ar"), + ("gcc_linker_candidate", "/usr/bin/ld"), + ("gcc_assembler_candidate", "/usr/bin/as"), + ("configured_cc1", "/usr/libexec/gcc/example/cc1"), + ("configured_collect2", "/usr/libexec/gcc/example/collect2"), + ("configured_as", "/usr/bin/as"), + ("configured_ld", "/usr/bin/ld"), + ("compiler_runtime", "/usr/lib/gcc/example/libgcc.a"), + ] + .into_iter() + .map(|(role, path)| { + ( + role.to_owned(), + SelectedFile { + selected: path.into(), + resolved: path.into(), + sha256: "c".repeat(64), + size: 128, + }, + ) + }) + .collect(), + compiler_queries: BTreeMap::new(), + sysroot: "/".to_owned(), + }, + profile_sha256: profile_digest(), + helper_sha256: "d".repeat(64), + build_boundary_fingerprint: hex::encode([1; 32]), + measurement_boundary_fingerprint: hex::encode([3; 32]), + plan_sha256: "b".repeat(64), + identity_scope: "fictional_projection_fixture", + acquisition: "unqualified_candidate_measurement", + full_closure_measured: false, + } +} + +fn measurement_fixture() -> Measurement { + let mut measurement = projection_fixture().measurement; + measurement.compiler_queries = [ + ("-print-prog-name=cc1", "/usr/libexec/gcc/example/cc1\n"), + ("-print-prog-name=collect2", "/usr/libexec/gcc/example/collect2\n"), + ("-print-prog-name=as", "as\n"), + ("-print-prog-name=ld", "ld\n"), + ("-print-libgcc-file-name", "/usr/lib/gcc/example/libgcc.a\n"), + ("-print-file-name=include", "/usr/lib/gcc/example/include\n"), + ("-print-sysroot", ""), + ("-print-search-dirs", "install: /usr/lib/gcc/example/\nprograms: =/usr/libexec/gcc/example/\nlibraries: =/usr/lib/gcc/example/\n"), + ] + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect(); + measurement +} + +#[test] +fn measurement_requires_exact_selected_roles_and_query_keys() { + for selected in [true, false] { + let mut measurement = measurement_fixture(); + if selected { + let value = measurement + .selected_files + .remove("compiler") + .expect("should have compiler"); + measurement + .selected_files + .insert("example_replacement".to_owned(), value); + } else { + let value = measurement + .compiler_queries + .remove("-print-sysroot") + .expect("should have sysroot query"); + measurement + .compiler_queries + .insert("example_replacement".to_owned(), value); + } + assert!( + validate_measurement(&measurement).is_err(), + "should reject a missing required identity even when counts match" + ); + } +} + +#[test] +fn measurement_binds_selected_drivers_and_configured_query_paths() { + for role in [ + "compiler", + "archiver", + "gcc_linker_candidate", + "gcc_assembler_candidate", + "configured_cc1", + "configured_collect2", + "configured_as", + "configured_ld", + "compiler_runtime", + ] { + let mut measurement = measurement_fixture(); + measurement + .selected_files + .get_mut(role) + .expect("should have selected role") + .selected = PathBuf::from("/usr/bin/example-replacement"); + assert!( + validate_measurement(&measurement).is_err(), + "should join selected {role} to its fixed driver or compiler query" + ); + } +} + +#[test] +fn measurement_rechecks_the_compiler_sysroot_observation() { + let mut measurement = measurement_fixture(); + measurement.compiler_queries.insert( + "-print-sysroot".to_owned(), + "/example-other-root\n".to_owned(), + ); + assert!( + validate_measurement(&measurement).is_err(), + "should reject a query contradicting the recorded image-root sysroot" + ); +} + +#[test] +fn measurement_preserves_reviewed_bare_names_and_resolved_aliases() { + for sysroot in ["", "\n", "/\n"] { + for explicit_paths in [false, true] { + let mut measurement = measurement_fixture(); + measurement + .compiler_queries + .insert("-print-sysroot".to_owned(), sysroot.to_owned()); + for name in ["as", "ld"] { + let role = format!("configured_{name}"); + let selected = &mut measurement + .selected_files + .get_mut(&role) + .expect("should have configured helper") + .selected; + *selected = PathBuf::from(format!("/bin/{name}")); + if explicit_paths { + measurement + .compiler_queries + .insert(format!("-print-prog-name={name}"), format!("/bin/{name}\n")); + } + } + measurement + .selected_files + .get_mut("compiler") + .expect("should have compiler") + .resolved = PathBuf::from("/usr/bin/example-gcc"); + validate_measurement(&measurement) + .expect("should accept measured aliases and supported GCC query spellings"); + } + } +} + +#[test] +fn measurement_rejects_bare_helpers_with_conflicting_image_identities() { + for role in ["configured_as", "configured_ld"] { + for changed in ["resolved", "sha256", "size"] { + let mut measurement = measurement_fixture(); + let file = measurement + .selected_files + .get_mut(role) + .expect("should have configured helper"); + match changed { + "resolved" => file.resolved = PathBuf::from("/usr/libexec/example-other-tool"), + "sha256" => file.sha256 = "d".repeat(64), + _ => file.size += 1, + } + assert!( + validate_measurement(&measurement).is_err(), + "should join bare {role} to the fixed image tool {changed}" + ); + } + } +} + +#[test] +fn measurement_accepts_an_explicit_alternate_image_helper() { + let mut measurement = measurement_fixture(); + let file = measurement + .selected_files + .get_mut("configured_as") + .expect("should have assembler"); + file.selected = PathBuf::from("/usr/libexec/example-as"); + file.resolved = file.selected.clone(); + file.sha256 = "d".repeat(64); + measurement.compiler_queries.insert( + "-print-prog-name=as".to_owned(), + "/usr/libexec/example-as\n".to_owned(), + ); + validate_measurement(&measurement) + .expect("should preserve an explicit measured compiler-selected helper"); +} + +#[test] +fn installed_image_identity_covers_helpers_headers_modes_and_aliases() { + let temporary = tempfile::tempdir().expect("should create image fixture"); + let root = temporary.path(); + fs::create_dir_all(root.join("usr/lib/gcc")).expect("should create resources"); + fs::create_dir_all(root.join("usr/include")).expect("should create headers"); + fs::write(root.join("usr/lib/gcc/cc1"), b"helper").expect("should write helper"); + fs::write(root.join("usr/include/example.h"), b"header").expect("should write header"); + symlink("usr/lib", root.join("lib")).expect("should create image alias"); + let first = installed_snapshot(root).expect("should measure image files"); + fs::write(root.join("usr/include/example.h"), b"changed").expect("should change header"); + assert_ne!( + first, + installed_snapshot(root).expect("should remeasure"), + "should bind header bytes" + ); + fs::write(root.join("usr/include/example.h"), b"header").expect("should restore header"); + fs::write(root.join("usr/lib/gcc/cc1"), b"changed").expect("should change helper"); + assert_ne!( + first, + installed_snapshot(root).expect("should remeasure"), + "should bind helper bytes" + ); + fs::write(root.join("usr/lib/gcc/cc1"), b"helper").expect("should restore helper"); + fs::remove_file(root.join("lib")).expect("should remove alias"); + symlink("usr/include", root.join("lib")).expect("should retarget alias"); + assert_ne!( + first, + installed_snapshot(root).expect("should remeasure"), + "should bind alias targets" + ); +} + +#[test] +fn selected_image_paths_reject_build_roots_and_unmeasured_files() { + for path in [ + "/boundary/writable/0/gcc", + "/tmp/cc1", + "/opt/cc1", + "/usr/../tmp/cc1", + ] { + assert!( + image_path(std::path::Path::new(path)).is_err(), + "should reject {path}" + ); + } + assert!( + image_path(std::path::Path::new("/usr/libexec/gcc/cc1")).is_ok(), + "should accept measured compiler resources" + ); +} + +#[test] +fn read_only_image_hardlinks_are_measured_and_selected_aliases_resolve_in_scope() { + let temporary = tempfile::tempdir().expect("should create image fixture"); + let root = temporary.path(); + fs::create_dir_all(root.join("usr/bin")).expect("should create image tools"); + fs::create_dir_all(root.join("usr/lib")).expect("should create image resources"); + fs::create_dir_all(root.join("tmp")).expect("should create outside scope"); + fs::write(root.join("usr/bin/gcc"), b"compiler").expect("should write image compiler"); + fs::hard_link(root.join("usr/bin/gcc"), root.join("usr/bin/cc")) + .expect("should create distro hardlink"); + let before = installed_snapshot(root).expect("should accept read-only image hardlinks"); + fs::write(root.join("usr/bin/cc"), b"changed").expect("should update fixture contents"); + assert_ne!( + before, + installed_snapshot(root).expect("should remeasure hardlinks"), + "should bind hardlinked bytes" + ); + assert!( + file_identity_at(root, std::path::Path::new("/usr/lib/../bin/gcc")).is_ok(), + "should accept GCC query dot segments resolving inside measured scope" + ); + fs::write(root.join("tmp/helper"), b"foreign").expect("should write foreign helper"); + symlink("../../tmp/helper", root.join("usr/bin/helper")) + .expect("should create escaping helper alias"); + assert!( + file_identity_at(root, std::path::Path::new("/usr/bin/helper")).is_err(), + "should reject a resolved helper outside measured image roots" + ); +} + +#[test] +fn helper_observations_must_agree_and_match_boundary_and_plan() { + let fixture = || GnuEvidence { + measurement: Measurement { + profile: PROFILE.to_owned(), + installed_files_sha256: "a".repeat(64), + content_unmeasured_files: Vec::new(), + roots: vec![], + selected_files: BTreeMap::new(), + compiler_queries: BTreeMap::new(), + sysroot: "/".to_owned(), + }, + profile_sha256: profile_digest(), + helper_sha256: "b".repeat(64), + build_boundary_fingerprint: hex::encode([1; 32]), + measurement_boundary_fingerprint: hex::encode([3; 32]), + plan_sha256: "c".repeat(64), + identity_scope: "fixture", + acquisition: "unqualified", + full_closure_measured: false, + }; + let first = fixture(); + let mut after = fixture(); + assert!( + first.verify_after(&after).is_ok(), + "should accept equal observations" + ); + after.measurement.installed_files_sha256 = "d".repeat(64); + assert!( + first.verify_after(&after).is_err(), + "should reject changed installed inputs" + ); + assert!( + first.verify_binding(&[1; 32], &"c".repeat(64)).is_ok(), + "should bind the actual completed invocation" + ); + assert!( + first.verify_binding(&[2; 32], &"c".repeat(64)).is_err(), + "should reject another completed boundary" + ); + assert!( + first.verify_binding(&[1; 32], &"d".repeat(64)).is_err(), + "should reject another completed plan" + ); + assert!( + first.verify_binding(&[3; 32], &"c".repeat(64)).is_err(), + "should not accept the measurement sandbox as the build boundary" + ); +} + +#[test] +fn binding_requires_a_separate_well_formed_measurement_sandbox() { + let mut evidence = GnuEvidence::projection_fixture(); + let plan = "b".repeat(64); + assert_eq!( + serde_json::to_value(&evidence).expect("should serialize the receipt")["measurement_boundary_fingerprint"], + hex::encode([3; 32]), + "should record the measurement sandbox fingerprint" + ); + assert!( + evidence.verify_binding(&[1; 32], &plan).is_ok(), + "should accept a distinct measurement sandbox" + ); + + evidence.measurement_boundary_fingerprint = hex::encode([1; 32]); + assert!( + evidence.verify_binding(&[1; 32], &plan).is_err(), + "should reject measurements recorded as the build boundary itself" + ); + evidence.measurement_boundary_fingerprint = "not-a-fingerprint".to_owned(); + assert!( + evidence.verify_binding(&[1; 32], &plan).is_err(), + "should reject a malformed measurement sandbox fingerprint" + ); +} + +/// Ordinary CI tools, a fictional GNU plan and a sandbox with `parent`. +struct DerivationFixture { + _directory: tempfile::TempDir, + tools: SelectedRustTools, + plan: CargoPlan, + sandbox: MeasurementSandbox, +} + +fn derivation_fixture(parent: Option<[u8; 32]>) -> DerivationFixture { + let directory = tempfile::tempdir().expect("should create fixture"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize fixture"); + let target = crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu; + let libraries = base.join(format!("tools/lib/rustlib/{}/lib", target.triple())); + fs::create_dir_all(&libraries).expect("should create tool libraries"); + fs::create_dir_all(base.join("tools/bin")).expect("should create tool binaries"); + for file in ["tools/bin/cargo", "tools/bin/rustc"] { + fs::write(base.join(file), b"fictional").expect("should write tool"); + } + fs::write(libraries.join("libstd-example.rlib"), b"fictional").expect("should write std"); + for root in ["checkout", "cargo-state"] { + fs::create_dir(base.join(root)).expect("should create root"); + } + let roots = crate::boundary::BoundaryRoots::new( + &[base.join("checkout")], + &[base.join("tools")], + &[base.join("cargo-state")], + ) + .expect("should build sandbox roots"); + let asset = super::super::AssetId::parse(crate::policy::FIXED_ARTIFACTS[0].name) + .expect("should select a fixed asset"); + DerivationFixture { + tools: SelectedRustTools::for_tests(&base.join("tools"), target) + .expect("should select fictional tools"), + plan: CargoPlan { + asset, + program: base.join("tools/bin/cargo"), + arguments: linker_arguments(), + environment: environment(), + working_directory: base.join("checkout"), + umask: crate::boundary::Umask::new(0o022).expect("should accept fixture umask"), + apple_identity_sha256: None, + }, + sandbox: MeasurementSandbox { + boundary: Boundary::derivation_fixture_for_tests(roots, parent) + .expect("should build the derivation fixture"), + proof: DenialProof::for_tests(), + }, + _directory: directory, + } +} + +#[test] +fn measurements_require_a_sandbox_derived_from_the_build_boundary() { + for parent in [None, Some([9; 32])] { + let fixture = derivation_fixture(parent); + let error = GnuEvidence::measure(&fixture.tools, &fixture.plan, &[1; 32], &fixture.sandbox) + .expect_err("should reject a sandbox from another build"); + assert!( + format!("{error:?}").contains("not derived from the build boundary"), + "should name the derivation rule for parent {parent:?}" + ); + } + + let fixture = derivation_fixture(Some([1; 32])); + let error = GnuEvidence::measure(&fixture.tools, &fixture.plan, &[1; 32], &fixture.sandbox) + .expect_err("should still require a container-owned sandbox"); + assert!( + format!("{error:?}").contains("requires the selected Docker boundary"), + "should pass derivation and reach the container gate" + ); +} + +#[test] +fn backend_switch_exception_accepts_only_zero() { + let root = std::path::Path::new("/example"); + let limits = crate::process::ProcessLimits { + wall: std::time::Duration::from_secs(1), + stdout_max: 1024, + stderr_max: 1024, + }; + for name in [ + "AWS_LC_SYS_CMAKE_BUILDER", + "AWS_LC_SYS_CMAKE_BUILDER_x86_64_unknown_linux_gnu", + ] { + assert!( + crate::process::BoundedCommand::new(&root.join("tool"), root, limits) + .expect("should describe command") + .environment(name, "0") + .is_ok(), + "should accept exact reviewed backend switch" + ); + assert!( + crate::process::BoundedCommand::new(&root.join("tool"), root, limits) + .expect("should describe command") + .environment(name, "1") + .is_err(), + "should reject alternate backend value" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe/identity.rs b/crates/trusted-server-release/src/recipe/identity.rs new file mode 100644 index 000000000..641070999 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/identity.rs @@ -0,0 +1,147 @@ +//! Shared source-owned recipe definitions, distinct from exact invocations. +//! +//! A definition selects the approved source policy. It does not prove that the +//! policy ran, authenticate the running bootstrap, or qualify tools/boundaries. + +use std::path::Path; + +use error_stack::Report; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +use super::{AssetId, CargoPlan, recipe_error}; +use crate::documents::{Role, canonical_json}; +use crate::error::ReleaseError; +use crate::source::{ApprovedSource, CommitId}; + +/// Canonical shared definition selected from approved source and a fixed asset. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct RecipeDefinitionIdentity { + descriptor: RecipeDefinition, + sha256: String, +} + +/// Source-owned recipe policy description, without execution authority. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct RecipeDefinition { + domain: &'static str, + schema_version: u64, + source_commit: String, + asset: &'static str, + package: &'static str, + binary: &'static str, + target: &'static str, + variant: &'static str, + cargo: CargoDefinition, + profile_definition: ProfileDefinition, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct CargoDefinition { + command: &'static str, + profile: &'static str, + default_features: bool, + features: Vec, + frozen: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct ProfileDefinition { + path: &'static str, + sha256: String, +} + +impl RecipeDefinitionIdentity { + /// Selects a common recipe definition without invocation-specific inputs. + /// + /// # Errors + /// + /// Rejects missing approved profile bytes or failed canonical serialization. + pub(crate) fn from_source( + source: &ApprovedSource, + asset: AssetId, + ) -> Result> { + let manifests = source.cargo_manifest_digests(); + let manifest_sha256 = manifests.get(Path::new("Cargo.toml")).ok_or_else(|| { + recipe_error("recipe definition requires an approved regular root Cargo.toml") + })?; + let artifact = asset.artifact(); + let descriptor = RecipeDefinition { + domain: "trusted-server.release.recipe-definition", + schema_version: 1, + source_commit: source.commit().as_str().to_owned(), + asset: artifact.name, + package: artifact.package, + binary: artifact.bin, + target: artifact.target.triple(), + variant: "default", + // The fixed cohort permits only default features. Profile bytes and + // all other implementation/tool policy are bound by approved source. + cargo: CargoDefinition { + command: "build", + profile: "release", + default_features: true, + features: Vec::new(), + frozen: true, + }, + profile_definition: ProfileDefinition { + path: "Cargo.toml", + sha256: manifest_sha256.clone(), + }, + }; + let sha256 = hash_definition(&descriptor)?; + Ok(Self { descriptor, sha256 }) + } + + /// The source-owned descriptor whose canonical bytes were hashed. + pub(crate) fn descriptor(&self) -> &RecipeDefinition { + &self.descriptor + } + + /// SHA-256 of the canonical domain-separated definition descriptor. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Checks the source and fixed asset without granting execution authority. + pub(super) fn matches_selection(&self, commit: &CommitId, asset: AssetId) -> bool { + let artifact = asset.artifact(); + self.descriptor.source_commit == commit.as_str() + && self.descriptor.asset == artifact.name + && self.descriptor.package == artifact.package + && self.descriptor.binary == artifact.bin + && self.descriptor.target == artifact.target.triple() + } +} + +/// Digest of the actual Cargo invocation, including paths and ambient choices. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub(crate) struct ExactInvocationDigest(String); + +impl ExactInvocationDigest { + /// Preserves the existing exact [`CargoPlan::digest`] calculation. + /// + /// # Errors + /// + /// Rejects plans whose exact digest cannot be serialized. + pub(crate) fn from_plan(plan: &CargoPlan) -> Result> { + plan.digest().map(Self) + } + + /// SHA-256 of the exact invocation description. + pub(crate) fn sha256(&self) -> &str { + &self.0 + } +} + +fn hash_definition(descriptor: &RecipeDefinition) -> Result> { + let value = + serde_json::to_value(descriptor).map_err(|error| recipe_error(error.to_string()))?; + let bytes = canonical_json(&value, Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/identity/tests.rs b/crates/trusted-server-release/src/recipe/identity/tests.rs new file mode 100644 index 000000000..cf36f7872 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/identity/tests.rs @@ -0,0 +1,182 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde_json::json; +use sha2::Sha256; + +use super::*; +use crate::boundary::Umask; +use crate::documents::{Role, canonical_json}; +use crate::policy::FIXED_ARTIFACTS; +use crate::source::CommitId; + +fn source(commit: char, manifest: bool) -> ApprovedSource { + ApprovedSource::in_memory_for_tests( + &CommitId::parse(&commit.to_string().repeat(40)) + .expect("should parse fictional source commit"), + if manifest { &["Cargo.toml"] } else { &[] }, + ) +} + +fn asset(index: usize) -> AssetId { + AssetId::parse(FIXED_ARTIFACTS[index].name).expect("should select a fixed cohort asset") +} + +fn plan() -> CargoPlan { + CargoPlan { + asset: asset(0), + program: PathBuf::from("/example/tools/bin/cargo"), + arguments: vec![ + "build".to_owned(), + "--release".to_owned(), + "--jobs".to_owned(), + "1".to_owned(), + ], + environment: BTreeMap::from([("LANG".to_owned(), "C".to_owned())]), + working_directory: PathBuf::from("/example/source-a"), + umask: Umask::new(0o022).expect("should accept canonical fixture umask"), + apple_identity_sha256: None, + } +} + +#[test] +fn shared_definition_binds_exact_source_owned_asset_and_profile_descriptor() { + let approved = source('a', true); + let definition = RecipeDefinitionIdentity::from_source(&approved, asset(0)) + .expect("should select source-owned recipe definition"); + let value = + serde_json::to_value(definition.descriptor()).expect("should serialize typed definition"); + assert_eq!( + value, + json!({"domain":"trusted-server.release.recipe-definition","schema_version":1,"source_commit":"a".repeat(40),"asset":FIXED_ARTIFACTS[0].name,"package":FIXED_ARTIFACTS[0].package,"binary":FIXED_ARTIFACTS[0].bin,"target":FIXED_ARTIFACTS[0].target.triple(),"variant":"default","cargo":{"command":"build","profile":"release","default_features":true,"features":[],"frozen":true},"profile_definition":{"path":"Cargo.toml","sha256":"0".repeat(64)}}), + "should bind only selected source policy and fixed asset fields" + ); + let bytes = canonical_json(&value, Role::Metadata).expect("should canonicalize descriptor"); + assert_eq!( + definition.sha256(), + hex::encode(Sha256::digest(&bytes)), + "should hash exact domain-separated descriptor bytes" + ); + assert_eq!( + definition, + RecipeDefinitionIdentity::from_source(&approved, asset(0)) + .expect("should repeat identical selection"), + "should keep identical source and asset stable" + ); +} + +#[test] +fn source_asset_target_and_profile_manifest_changes_change_definition_identity() { + let approved = source('a', true); + let original = RecipeDefinitionIdentity::from_source(&approved, asset(0)) + .expect("should select baseline definition"); + let changed_source = RecipeDefinitionIdentity::from_source(&source('b', true), asset(0)) + .expect("should select another approved source"); + assert_ne!( + original.sha256(), + changed_source.sha256(), + "should bind the exact approved source commit" + ); + for index in 1..FIXED_ARTIFACTS.len() { + let other = RecipeDefinitionIdentity::from_source(&approved, asset(index)) + .expect("should select another cohort asset and target"); + assert_ne!( + original.sha256(), + other.sha256(), + "should distinguish fixed asset/target {index}" + ); + } + let arm = RecipeDefinitionIdentity::from_source(&approved, asset(1)) + .expect("should select ARM CLI definition"); + let intel = RecipeDefinitionIdentity::from_source(&approved, asset(2)) + .expect("should select Intel CLI definition"); + assert_ne!( + arm.sha256(), + intel.sha256(), + "should distinguish the same package/binary on different targets" + ); + let mut changed_manifest = original.descriptor().clone(); + changed_manifest.profile_definition.sha256 = "1".repeat(64); + assert_ne!( + original.sha256(), + hash_definition(&changed_manifest).expect("should hash a changed private descriptor"), + "should include the raw approved release-profile manifest digest" + ); +} + +#[test] +fn missing_approved_root_manifest_and_unknown_assets_are_rejected() { + assert!( + RecipeDefinitionIdentity::from_source(&source('a', false), asset(0)).is_err(), + "should require the approved tracked profile manifest" + ); + assert!( + AssetId::parse("example-unknown-asset").is_err(), + "should preserve the existing fixed-asset constructor boundary" + ); +} + +#[test] +fn retained_definition_matches_only_its_approved_source_and_fixed_asset() { + let approved = source('a', true); + let identity = RecipeDefinitionIdentity::from_source(&approved, asset(0)) + .expect("should select a source-owned definition"); + assert!( + identity.matches_selection(approved.commit(), asset(0)), + "should recognize the same immutable source and fixed asset" + ); + assert!( + !identity.matches_selection(source('b', true).commit(), asset(0)), + "should reject another source commit at the checked handoff" + ); + assert!( + !identity.matches_selection(approved.commit(), asset(1)), + "should reject another fixed asset at the checked handoff" + ); +} + +#[test] +fn exact_invocations_preserve_existing_digest_and_ab_variations_do_not_change_definition() { + let approved = source('a', true); + let shared = RecipeDefinitionIdentity::from_source(&approved, asset(0)) + .expect("should select common definition"); + let canonical = plan(); + let exact = ExactInvocationDigest::from_plan(&canonical) + .expect("should bind exact canonical invocation"); + assert_eq!( + exact.sha256(), + canonical + .digest() + .expect("should calculate existing plan digest"), + "should preserve the existing exact invocation digest bytes" + ); + for variation in ["jobs", "umask", "root", "environment", "tool"] { + let mut changed = canonical.clone(); + match variation { + "jobs" => changed.arguments[3] = "4".to_owned(), + "umask" => { + changed.umask = Umask::new(0o027).expect("should accept reproduction fixture umask") + } + "root" => changed.working_directory = PathBuf::from("/example/source-b"), + "environment" => { + changed + .environment + .insert("LANG".to_owned(), "en_US.UTF-8".to_owned()); + } + "tool" => changed.program = PathBuf::from("/example/other-tools/bin/cargo"), + _ => unreachable!(), + } + let observed = + ExactInvocationDigest::from_plan(&changed).expect("should bind varied invocation"); + assert_ne!( + exact, observed, + "should retain actual {variation} differences in invocation identity" + ); + assert_eq!( + shared, + RecipeDefinitionIdentity::from_source(&approved, changed.asset) + .expect("should retain shared source-owned definition"), + "should exclude observed {variation} from shared recipe definition" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe/native.rs b/crates/trusted-server-release/src/recipe/native.rs new file mode 100644 index 000000000..9fb388532 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/native.rs @@ -0,0 +1,212 @@ +//! Native executable evidence: repeatability measured, operator values gated. +//! +//! Complete final-byte equality is a shipping gate for Fastly only. A native +//! `ts` executable built once canonically and once as a reproduction is +//! compared over identical inputs and the outcome is recorded either way, so +//! a nonmatching native pair is a preserved measurement, never a rejection. +//! +//! Operator configuration must never be compiled in. A build whose inherited +//! environment carried fictional operator values (which preflight removes) +//! must hold none of them in its final bytes; that scan is a gate. + +use error_stack::Report; +use object::Endianness; +use object::elf::{FileHeader64, SHT_NOBITS}; +use object::read::elf::{FileHeader as _, SectionHeader as _}; +use sha2::{Digest as _, Sha256}; + +use super::ambient::{AmbientClaim, AmbientRecord}; +use super::compare::{check_pair, first_difference, read_artifact}; +use super::{AssetId, RecipeCompleted, recipe_error}; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; + +/// Size and digest of one final executable. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ArtifactIdentity { + /// Lowercase hex SHA-256 of the final bytes. + pub(crate) sha256: String, + /// Length of the final bytes. + pub(crate) size: u64, +} + +impl ArtifactIdentity { + fn of(bytes: &[u8]) -> Self { + Self { + sha256: hex::encode(Sha256::digest(bytes)), + size: bytes.len() as u64, + } + } +} + +/// Where a nonmatching native pair first differs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct NativeDifference { + /// First differing offset (the shorter length for a strict prefix). + pub(crate) first_offset: usize, + /// Differing positions plus the length difference. + pub(crate) differing_bytes: usize, + /// The canonical ELF section holding the offset, when one does. + pub(crate) canonical_section: Option, +} + +/// The recorded repeatability of one native canonical/reproduction pair. +/// +/// It has private fields and only [`measure_native_repeatability`] +/// constructs it; a difference is data, not an error. +#[derive(Debug)] +pub(crate) struct NativeRepeatability { + asset: AssetId, + canonical: ArtifactIdentity, + reproduction: ArtifactIdentity, + difference: Option, + ambient: AmbientRecord, +} + +impl NativeRepeatability { + /// The compared native asset. + pub(crate) fn asset(&self) -> AssetId { + self.asset + } + + /// Identity of the canonical final bytes. + pub(crate) fn canonical(&self) -> &ArtifactIdentity { + &self.canonical + } + + /// Identity of the reproduction final bytes. + pub(crate) fn reproduction(&self) -> &ArtifactIdentity { + &self.reproduction + } + + /// Whether the two final files are byte-identical. + pub(crate) fn identical(&self) -> bool { + self.difference.is_none() + } + + /// Where the files first differ, if they do. + pub(crate) fn difference(&self) -> Option<&NativeDifference> { + self.difference.as_ref() + } + + /// The verified ambient pairing of the two builds. + pub(crate) fn ambient(&self) -> &AmbientRecord { + &self.ambient + } +} + +/// Measures whether a native canonical and reproduction build produced +/// identical final bytes, recording a mismatch instead of failing on it. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for the Fastly asset (which takes the +/// equality gate instead), a pair that is not the canonical then the +/// reproduction build of one asset over identical source, lock, materials, +/// manifests, JS handoff and tools, an ambient pairing that does not vary +/// exactly what [`AmbientClaim::standard`] states, or an artifact that no +/// longer rehashes. A byte difference is never an error. +pub(crate) fn measure_native_repeatability( + canonical: &RecipeCompleted, + reproduction: &RecipeCompleted, +) -> Result> { + let asset = canonical.asset(); + if asset.artifact().target == ReleaseTarget::Wasm32Wasip1 { + return Err(recipe_error( + "Fastly outputs take the equality gate, not a native measurement", + )); + } + check_pair(canonical, reproduction, asset)?; + let ambient = AmbientRecord::pair( + canonical.ambient(), + reproduction.ambient(), + &AmbientClaim::standard(), + )?; + let canonical_bytes = read_artifact(canonical)?; + let reproduction_bytes = read_artifact(reproduction)?; + let difference = first_difference(&canonical_bytes, &reproduction_bytes).map(|first_offset| { + NativeDifference { + first_offset, + differing_bytes: differing_bytes(&canonical_bytes, &reproduction_bytes), + canonical_section: elf_section_at(&canonical_bytes, first_offset), + } + }); + Ok(NativeRepeatability { + asset, + canonical: ArtifactIdentity::of(&canonical_bytes), + reproduction: ArtifactIdentity::of(&reproduction_bytes), + difference, + ambient, + }) +} + +/// Counts differing positions plus the length difference. +fn differing_bytes(first: &[u8], second: &[u8]) -> usize { + first + .iter() + .zip(second) + .filter(|(left, right)| left != right) + .count() + + first.len().abs_diff(second.len()) +} + +/// Names the ELF section whose file bytes hold `offset`, if the file parses +/// as a 64-bit ELF and one does. +pub(crate) fn elf_section_at(bytes: &[u8], offset: usize) -> Option { + let header = FileHeader64::::parse(bytes).ok()?; + let endian = header.endian().ok()?; + let sections = header.sections(endian, bytes).ok()?; + let offset = u64::try_from(offset).ok()?; + sections.iter().find_map(|section| { + let start = section.sh_offset(endian); + let end = start.checked_add(section.sh_size(endian))?; + let holds = section.sh_type(endian) != SHT_NOBITS && (start..end).contains(&offset); + holds + .then(|| sections.section_name(endian, section).ok()) + .flatten() + .map(|name| String::from_utf8_lossy(name).into_owned()) + }) +} + +/// Most planted operator values one scan accepts. +const MAX_OPERATOR_VALUES: usize = 16; +/// Shortest planted value: anything shorter could occur by chance. +const MIN_OPERATOR_VALUE_BYTES: usize = 12; + +/// Requires the final bytes to hold none of the planted operator `values`. +/// +/// Each value is a distinctive fictional string that the caller placed in +/// the inherited environment as operator configuration. Preflight removes +/// such variables, so finding one in the executable means configuration +/// reached the build through another channel. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for no values, more than 16, a value +/// shorter than 12 bytes, or any value found in `bytes`. +pub(crate) fn check_operator_values_absent( + bytes: &[u8], + values: &[&str], +) -> Result<(), Report> { + if values.is_empty() || values.len() > MAX_OPERATOR_VALUES { + return Err(recipe_error( + "an operator value scan needs between 1 and 16 planted values", + )); + } + for value in values { + if value.len() < MIN_OPERATOR_VALUE_BYTES { + return Err(recipe_error( + "a planted operator value is too short to be distinctive", + )); + } + if bytes + .windows(value.len()) + .any(|window| window == value.as_bytes()) + { + return Err(recipe_error(format!( + "final bytes embed the planted operator value {value:?}" + ))); + } + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/recipe/paths.rs b/crates/trusted-server-release/src/recipe/paths.rs new file mode 100644 index 000000000..7270445ca --- /dev/null +++ b/crates/trusted-server-release/src/recipe/paths.rs @@ -0,0 +1,79 @@ +//! Translation between host paths and the build's execution namespace. +//! +//! The confined command sees the boundary roots under their execution paths +//! (identical on macOS, `/boundary//` mount points in the Linux container). Every +//! path the recipe hands to Cargo, and every path Cargo reports back, crosses +//! this seam, so the recipe never guesses a mount layout. + +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use super::recipe_error; +use crate::boundary::Boundary; +use crate::error::ReleaseError; + +/// Maps host paths into the execution namespace and writable paths back. +pub(crate) trait ExecutionPaths { + /// Translates a host path inside any boundary root. + /// + /// # Errors + /// + /// Returns an error when `host` is outside every root. + fn execution_path(&self, host: &Path) -> Result>; + + /// Translates an execution path inside a writable root back to the host. + /// + /// # Errors + /// + /// Returns an error when `execution` is outside every writable root, so + /// a build can never name an immutable or tool file as its output. + fn host_path(&self, execution: &Path) -> Result>; +} + +impl ExecutionPaths for Boundary { + fn execution_path(&self, host: &Path) -> Result> { + Boundary::execution_path(self, host) + } + + fn host_path(&self, execution: &Path) -> Result> { + Boundary::host_path(self, execution) + } +} + +/// Translates `host` and requires UTF-8 text without `:` or NUL, so the +/// result can travel in an environment variable, a `PATH` or a Cargo config. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when `host` is outside the roots or the +/// translation is not plain text. +pub(super) fn execution_text( + paths: &dyn ExecutionPaths, + label: &str, + host: &Path, +) -> Result> { + let translated = paths + .execution_path(host) + .map_err(|error| error.change_context(ReleaseError::Recipe)) + .map_err(|error| error.attach(format!("{label} is outside the boundary roots")))?; + translated + .to_str() + .filter(|text| { + translated.is_absolute() + && !text.contains([':', '"', '\\']) + && !text.chars().any(char::is_control) + }) + .map(str::to_owned) + .ok_or_else(|| { + recipe_error(format!( + "{label} has an execution path that is not plain absolute UTF-8" + )) + }) +} + +#[cfg(test)] +pub(super) use fake::PrefixPaths; + +#[cfg(test)] +mod fake; diff --git a/crates/trusted-server-release/src/recipe/paths/fake.rs b/crates/trusted-server-release/src/recipe/paths/fake.rs new file mode 100644 index 000000000..ddb3b2b0a --- /dev/null +++ b/crates/trusted-server-release/src/recipe/paths/fake.rs @@ -0,0 +1,93 @@ +//! A prefix-table translator for tests that never touch a real boundary. + +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use super::ExecutionPaths; +use crate::error::ReleaseError; +use crate::recipe::recipe_error; + +/// One mapped root: host prefix, execution prefix and writability. +#[derive(Clone, Debug)] +pub(crate) struct MappedRoot { + host: PathBuf, + execution: PathBuf, + writable: bool, +} + +/// Translates through an explicit table of mapped roots. +#[derive(Clone, Debug, Default)] +pub(crate) struct PrefixPaths { + roots: Vec, +} + +impl PrefixPaths { + /// Creates an empty table. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Adds a root mapped from `host` to `execution`. + #[must_use] + pub(crate) fn with(mut self, host: &Path, execution: &str, writable: bool) -> Self { + self.roots.push(MappedRoot { + host: host.to_owned(), + execution: PathBuf::from(execution), + writable, + }); + self + } +} + +impl ExecutionPaths for PrefixPaths { + fn execution_path(&self, host: &Path) -> Result> { + self.roots + .iter() + .find_map(|root| { + host.strip_prefix(&root.host).ok().map(|relative| { + if relative.as_os_str().is_empty() { + root.execution.clone() + } else { + root.execution.join(relative) + } + }) + }) + .ok_or_else(|| recipe_error(format!("{} is outside the fake roots", host.display()))) + } + + fn host_path(&self, execution: &Path) -> Result> { + let normal = execution.components().collect::().as_os_str() + == execution.as_os_str() + && !execution + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)); + if !normal { + return Err(recipe_error(format!( + "{} is not in lexical normal form", + execution.display() + ))); + } + self.roots + .iter() + .filter(|root| root.writable) + .find_map(|root| { + execution + .strip_prefix(&root.execution) + .ok() + .map(|relative| { + if relative.as_os_str().is_empty() { + root.host.clone() + } else { + root.host.join(relative) + } + }) + }) + .ok_or_else(|| { + recipe_error(format!( + "{} is outside the fake writable roots", + execution.display() + )) + }) + } +} diff --git a/crates/trusted-server-release/src/recipe/plan.rs b/crates/trusted-server-release/src/recipe/plan.rs new file mode 100644 index 000000000..94eeaf7eb --- /dev/null +++ b/crates/trusted-server-release/src/recipe/plan.rs @@ -0,0 +1,448 @@ +//! The exact Cargo invocation of one recipe, in the build's own namespace. +//! +//! Host paths exist only in `program` and `working_directory`, which +//! [`ConfinedCommand`] translates itself. Every path inside the argument +//! vector and the environment goes through [`ExecutionPaths`], so a root the +//! boundary does not mount is an error here instead of a dangling path in the +//! child. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +use error_stack::Report; +use serde_json::json; +use sha2::{Digest as _, Sha256}; + +use super::ambient::CheckedAmbient; +use super::apple::{AppleDeveloperTools, REMAPPED_SDK}; +use super::paths::{ExecutionPaths, execution_text}; +use super::remap::{RemapSources, c_prefix_map_environment_with, remap_arguments_with}; +use super::sources::source_replacement_arguments; +use super::tools::SelectedRustTools; +use super::{AssetId, RecipeRequest, recipe_error}; +use crate::boundary::{ConfinedCommand, Umask}; +use crate::documents::{Role, canonical_json}; +use crate::error::ReleaseError; +use crate::materials::PreparedCargoMaterials; +use crate::preflight::child::ChildEnvironment; +use crate::process::ProcessLimits; + +const PREBUILT_DIR_VARIABLE: &str = "TSJS_PREBUILT_DIR"; +const PREBUILT_INVENTORY_VARIABLE: &str = "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY"; +const TARGET_DIR_VARIABLE: &str = "CARGO_TARGET_DIR"; +const CARGO_HOME_VARIABLE: &str = "CARGO_HOME"; +const HOME_VARIABLE: &str = "HOME"; +const TMPDIR_VARIABLE: &str = "TMPDIR"; +const RUSTC_VARIABLE: &str = "RUSTC"; +const PATH_VARIABLE: &str = "PATH"; +const DEVELOPER_DIR_VARIABLE: &str = "DEVELOPER_DIR"; +const SDKROOT_VARIABLE: &str = "SDKROOT"; +const ZERO_AR_DATE_VARIABLE: &str = "ZERO_AR_DATE"; + +/// The exact Cargo invocation of one recipe. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CargoPlan { + pub(super) asset: AssetId, + pub(super) program: PathBuf, + pub(super) arguments: Vec, + pub(super) environment: BTreeMap, + pub(super) working_directory: PathBuf, + pub(super) umask: Umask, + /// Digest of the Apple developer tool record a Darwin plan links with. + pub(super) apple_identity_sha256: Option, +} + +/// Fixed supporting observations of the selected explicit-target build. +#[derive(Clone, Copy, Debug)] +pub(super) enum CargoQuery { + /// Unfiltered identities, including host-only build dependencies. + Metadata, + /// Selected package's non-dev dependency tree, retained as raw evidence. + Tree, +} + +impl CargoPlan { + /// Derives supporting queries without changing tools, inputs or the boundary. + /// + /// Compiler messages remain the authority for effective unit features. + /// Metadata/tree features are only package-level graph observations. + /// + /// # Errors + /// + /// Rejects incomplete config/feature argument pairs in the selected plan. + pub(super) fn observation_query(&self, kind: CargoQuery) -> Result> { + let artifact = self.asset.artifact(); + let arguments: &[&str] = match kind { + CargoQuery::Metadata => &["metadata", "--format-version", "1", "--frozen"], + CargoQuery::Tree => &[ + "tree", + "--package", + artifact.package, + "--target", + artifact.target.triple(), + "--edges", + "normal,build", + "--prefix", + "depth", + "--format", + "{p}", + "--charset", + "ascii", + "--no-dedupe", + "--frozen", + ], + }; + let mut query = self.clone(); + query.arguments = arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect(); + let mut selected = self.arguments.iter(); + while let Some(argument) = selected.next() { + match argument.as_str() { + "--config" | "--features" => { + let value = selected.next().ok_or_else(|| { + recipe_error("incomplete selected Cargo config or features") + })?; + query.arguments.extend([argument.clone(), value.clone()]); + } + "--all-features" | "--no-default-features" => { + query.arguments.push(argument.clone()) + } + _ => {} + } + } + Ok(query) + } + + /// Describes the plan as a confined command running under the role's umask. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for a non-normal program or working + /// directory, or [`ReleaseError::Process`] for a rejected variable. + pub(crate) fn confined_command( + &self, + limits: ProcessLimits, + ) -> Result> { + let mut command = ConfinedCommand::new(&self.program, &self.working_directory, limits)? + .arguments(self.arguments.iter().map(OsString::from)) + .umask(self.umask); + for (name, value) in &self.environment { + // The compiler path is the one tool selector the plan sets, on purpose. + command = if name == RUSTC_VARIABLE { + command.tool_environment(name, value)? + } else { + command.environment(name, value)? + }; + } + Ok(command) + } + + /// Returns the SHA-256 of the canonical description of the whole plan. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when a path is not UTF-8. + pub(crate) fn digest(&self) -> Result> { + let text = |label: &str, path: &std::path::Path| { + path.to_str() + .map(str::to_owned) + .ok_or_else(|| recipe_error(format!("plan {label} is not UTF-8"))) + }; + let mut description = json!({ + "asset": self.asset.artifact().name, + "program": text("program", &self.program)?, + "arguments": self.arguments, + "environment": self.environment, + "working_directory": text("working directory", &self.working_directory)?, + "umask": self.umask.to_string(), + }); + if let Some(identity) = &self.apple_identity_sha256 { + description["apple_developer_tools_sha256"] = json!(identity); + } + let bytes = canonical_json(&description, Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + Ok(hex::encode(Sha256::digest(bytes))) + } +} + +/// Returns the fixed argument vector of the release Cargo build. +/// +/// The only `--config` entries are `configs`: the exact source replacements +/// followed by the target path remap. +pub(super) fn cargo_arguments(asset: AssetId, jobs: u32, configs: &[String]) -> Vec { + let artifact = asset.artifact(); + [ + "build", + "--package", + artifact.package, + "--bin", + artifact.bin, + "--target", + artifact.target.triple(), + "--release", + "--frozen", + ] + .into_iter() + .map(str::to_owned) + .chain(configs.iter().cloned()) + .chain( + ["--message-format=json-render-diagnostics", "--jobs"] + .into_iter() + .map(str::to_owned), + ) + .chain([jobs.to_string()]) + .collect() +} + +/// Requires the child's `HOME` and `CARGO_HOME` to be the request's private state. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when either variable is missing or names +/// another location. +pub(super) fn check_child_state( + request: &RecipeRequest, + child: &ChildEnvironment, +) -> Result<(), Report> { + for (name, host) in [ + (HOME_VARIABLE, request.home()), + (CARGO_HOME_VARIABLE, request.cargo_home()), + ] { + if child.variables().get(name).map(String::as_str) != host.to_str() { + return Err(recipe_error(format!( + "child {name} is not the request's private cargo state" + ))); + } + } + Ok(()) +} + +/// Everything [`plan_for`] derives the plan from. +pub(super) struct PlanInputs<'a> { + pub(super) request: &'a RecipeRequest, + pub(super) tools: &'a SelectedRustTools, + pub(super) ambient: &'a CheckedAmbient, + pub(super) child: &'a ChildEnvironment, + pub(super) materials: &'a PreparedCargoMaterials, + pub(super) paths: &'a dyn ExecutionPaths, +} + +/// Derives the exact Cargo invocation from the bound inputs. +/// +/// The arguments carry the source replacement and the deterministic target +/// path remap of [`super::remap`]; a native asset's environment also carries +/// the same remap for C code. All of it is bound by [`CargoPlan::digest`]. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the child environment's `HOME` or +/// `CARGO_HOME` is not the request's private state, or any path variable, +/// the tool directory, the vendor directory, the checkout root or the Rust +/// tool root lies outside the boundary roots or is not plain text, two +/// remapped roots overlap in the execution namespace, or a native asset's +/// remapped root holds whitespace. +pub(super) fn plan_for(inputs: &PlanInputs<'_>) -> Result> { + let PlanInputs { + request, + tools, + ambient, + child, + materials, + paths, + } = inputs; + check_child_state(request, child)?; + let cargo_home = request.cargo_home(); + let home = request.home(); + let tmpdir = request.tmpdir(); + let rustc = tools.rustc(); + let mut environment = child.variables().clone(); + for (name, host) in [ + (TARGET_DIR_VARIABLE, request.target_dir()), + (CARGO_HOME_VARIABLE, cargo_home.as_path()), + (HOME_VARIABLE, home.as_path()), + (TMPDIR_VARIABLE, tmpdir.as_path()), + (PREBUILT_DIR_VARIABLE, request.js_dir()), + (PREBUILT_INVENTORY_VARIABLE, request.js_inventory()), + (RUSTC_VARIABLE, rustc.as_path()), + ] { + environment.insert(name.to_owned(), execution_text(*paths, name, host)?); + } + let tool_bin = execution_text(*paths, PATH_VARIABLE, &tools.bin_dir())?; + environment.insert( + PATH_VARIABLE.to_owned(), + format!("{tool_bin}:/usr/bin:/bin"), + ); + let vendor = execution_text(*paths, "vendor directory", materials.vendor_dir())?; + let mut configs = source_replacement_arguments(&vendor, materials.git_sources()); + let target = request.asset().artifact().target; + let checkout = execution_text(*paths, "checkout root", ambient.checkout_root())?; + let tool_root = execution_text(*paths, "Rust tools root", tools.root())?; + let darwin = tools + .apple() + .map(|apple| darwin_plan(*paths, target, apple, &tool_root)) + .transpose()?; + let sources = RemapSources { + checkout: &checkout, + vendor: &vendor, + cargo_home: &environment[CARGO_HOME_VARIABLE], + tools: &tool_root, + target: &environment[TARGET_DIR_VARIABLE], + }; + let link_flags = darwin + .as_ref() + .map_or(&[][..], |darwin| darwin.rustflags.as_slice()); + configs.extend(remap_arguments_with(target.triple(), &sources, link_flags)?); + if target == crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu { + configs.extend(super::gnu::linker_arguments()); + } + if target == crate::policy::ReleaseTarget::Wasm32Wasip1 + && cfg!(all(target_os = "linux", target_arch = "x86_64")) + { + configs.extend(super::fastly_tools::linker_arguments(&tool_root)); + } + let developer = darwin.as_ref().map(|darwin| darwin.developer.as_str()); + let c_prefix_map = c_prefix_map_environment_with(target, &sources, developer)?; + if let Some((name, mut value)) = c_prefix_map { + if let Some(sdk) = darwin.as_ref().and_then(|darwin| darwin.sdk.as_deref()) { + super::fresh::disjoint(&[ + ("owned SDK", std::path::Path::new(sdk)), + ("checkout", std::path::Path::new(&checkout)), + ("vendor", std::path::Path::new(&vendor)), + ( + "Cargo home", + std::path::Path::new(&environment[CARGO_HOME_VARIABLE]), + ), + ("Rust tools", std::path::Path::new(&tool_root)), + ( + "target", + std::path::Path::new(&environment[TARGET_DIR_VARIABLE]), + ), + ( + "Apple developer", + std::path::Path::new(developer.expect("should have Darwin developer root")), + ), + ])?; + value.push_str(&format!(" -isysroot {sdk} -ffile-prefix-map={sdk}={REMAPPED_SDK} -fdebug-prefix-map={sdk}={REMAPPED_SDK}")); + } + environment.insert(name, value); + } + if target == crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu { + environment.extend(super::gnu::environment()); + } + if target == crate::policy::ReleaseTarget::Wasm32Wasip1 + && cfg!(all(target_os = "linux", target_arch = "x86_64")) + { + environment.extend(super::fastly_tools::environment()); + } + let apple_identity_sha256 = match darwin { + Some(darwin) => { + configs.extend(darwin.configs); + environment.extend(darwin.environment); + Some(darwin.identity_sha256) + } + None => None, + }; + Ok(CargoPlan { + asset: request.asset(), + program: tools.cargo(), + arguments: cargo_arguments(request.asset(), ambient.jobs(), &configs), + environment, + working_directory: ambient.checkout_root().to_owned(), + umask: ambient.umask()?, + apple_identity_sha256, + }) +} + +/// The Darwin additions to one plan, all in the execution namespace. +struct DarwinPlan { + /// Linker arguments appended inside the target rustflags entry. + rustflags: Vec, + /// Extra `--config` pairs: the target linker. + configs: Vec, + /// `DEVELOPER_DIR`, `SDKROOT`, `CC_`, `AR_` and + /// `ZERO_AR_DATE` and the fixed Cargo OpenSSL configuration. + environment: BTreeMap, + /// Execution path of the developer directory for the C path remap. + developer: String, + /// Execution path of a separately owned SDK. + sdk: Option, + /// Digest of the Apple tool identity record. + identity_sha256: String, +} + +/// Derives the Darwin linker, C tool and debug-map settings. +/// +/// The Mach-O debug map is kept, but `-oso_prefix` makes the linker strip +/// the recipe-owned parent of the Rust tool root (and, as binding requires, +/// of the target directory) from every object path, and `ZERO_AR_DATE=1` +/// makes it record zero object modification times. The linker is selected +/// through `--config target."".linker` because the environment form +/// is a denied tool selector; `xcrun` and the `/usr/bin` shims are never used. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the target is not Darwin, an Apple +/// path is outside the boundary roots or not plain text, or the parent of the +/// Rust tool root is missing or holds a `,` that `-Wl,` would split. +fn darwin_plan( + paths: &dyn ExecutionPaths, + target: crate::policy::ReleaseTarget, + apple: &AppleDeveloperTools, + tool_root: &str, +) -> Result> { + if !target.is_darwin() { + return Err(recipe_error( + "Apple developer tools apply only to Darwin targets", + )); + } + let developer = execution_text(paths, "Apple developer directory", apple.developer_dir())?; + let sdk = apple + .owned_sdk_root() + .map(|root| execution_text(paths, "owned Apple SDK", root)) + .transpose()?; + let clang = execution_text(paths, "Apple clang", apple.clang())?; + let parent = std::path::Path::new(tool_root) + .parent() + .and_then(std::path::Path::to_str) + .filter(|parent| *parent != "/" && !parent.contains(',')) + .ok_or_else(|| recipe_error("Rust tool root has no plain recipe-owned parent"))?; + let triple = target.triple(); + let underscored = triple.replace('-', "_"); + let environment = BTreeMap::from([ + (DEVELOPER_DIR_VARIABLE.to_owned(), developer.clone()), + ( + SDKROOT_VARIABLE.to_owned(), + execution_text(paths, "Apple SDK root", apple.sdk_root())?, + ), + (format!("CC_{underscored}"), clang.clone()), + ( + format!("AR_{underscored}"), + execution_text(paths, "Apple ar", apple.ar())?, + ), + (ZERO_AR_DATE_VARIABLE.to_owned(), "1".to_owned()), + // Cargo's LibreSSL initialization must not read ambient host configuration. + ("OPENSSL_CONF".to_owned(), "/dev/null".to_owned()), + ]); + let mut rustflags = vec![format!("-Clink-arg=-Wl,-oso_prefix,{parent}/")]; + if let Some(sdk) = &sdk { + rustflags.extend([ + format!("--remap-path-prefix={sdk}={REMAPPED_SDK}"), + "-Clink-arg=-isysroot".to_owned(), + format!("-Clink-arg={sdk}"), + ]); + } + Ok(DarwinPlan { + rustflags, + configs: vec![ + "--config".to_owned(), + format!("target.\"{triple}\".linker=\"{clang}\""), + ], + environment, + developer, + sdk, + identity_sha256: apple.identity_sha256()?, + }) +} diff --git a/crates/trusted-server-release/src/recipe/remap.rs b/crates/trusted-server-release/src/recipe/remap.rs new file mode 100644 index 000000000..e9fd4c15f --- /dev/null +++ b/crates/trusted-server-release/src/recipe/remap.rs @@ -0,0 +1,347 @@ +//! Deterministic compiler path remapping for the target units of one build. +//! +//! Debug info (`debug = 1`), panic locations and `file!()` embed the paths +//! rustc compiled from. Every root those paths can come from is remapped to a +//! fixed prefix, so the final bytes do not depend on where a build's +//! checkout, vendor tree, Cargo home, Rust tools or target directory live. +//! +//! # Cargo precedence +//! +//! Cargo takes rustflags from the first source that defines any: +//! `CARGO_ENCODED_RUSTFLAGS`, then `RUSTFLAGS`, then every matching +//! `target..rustflags` and `target..rustflags` joined, and only +//! then `build.rustflags`. A `--config` value merges with the configuration +//! files and arrays concatenate with the command line last. The remap +//! therefore travels as one `--config target."".rustflags=[...]` +//! entry: reviewed target flags in `.cargo/config.toml` stay in effect before +//! it. Either environment variable would replace every configured flag, so +//! the audited environment rejects both and the plan never sets them. Any +//! target rustflags supersede `build.rustflags`, so the reviewed +//! configuration must not set it ([`check_cargo_config`]), and the legacy +//! extensionless `.cargo/config` layer is refused outright +//! ([`check_no_extensionless_config`]). +//! +//! # Ordering +//! +//! When several `--remap-path-prefix` flags match one path, rustc applies the +//! last matching one. The remapped roots are required to be pairwise +//! disjoint, so at most one ever matches and the emitted order cannot change +//! the result. +//! +//! # Limitation +//! +//! Remapping rewrites paths rustc knows as source locations. A string built +//! by `env!("CARGO_MANIFEST_DIR")`, `env!("OUT_DIR")` or `concat!` of them is +//! an ordinary literal and keeps the execution path. The final bytes are +//! therefore scanned ([`scan_remapped_bytes`]) so such a literal fails the +//! build evidence instead of passing silently. +//! +//! With an explicit `--target`, rustflags reach only target units, never +//! build scripts or procedural macros: exactly the code in the artifact. +//! C code those build scripts compile into a native artifact gets the same +//! maps through [`c_prefix_map_environment`]. + +use std::io; +use std::path::Path; + +use error_stack::Report; + +use super::apple::REMAPPED_DEVELOPER; +use super::fresh::disjoint; +use super::recipe_error; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; + +/// Tracked Cargo configuration Cargo reads from the checkout root. +pub(super) const CARGO_CONFIG: &str = ".cargo/config.toml"; +/// Legacy extensionless configuration Cargo also reads; always refused. +pub(super) const LEGACY_CARGO_CONFIG: &str = ".cargo/config"; +/// Fixed prefix of the source checkout. +pub(super) const REMAPPED_CHECKOUT: &str = "/trusted-server"; +/// Fixed prefix of the vendored dependency directory. +pub(super) const REMAPPED_VENDOR: &str = "/cargo-vendor"; +/// Fixed prefix of the private Cargo home. +pub(super) const REMAPPED_CARGO_HOME: &str = "/cargo-home"; +/// Fixed prefix of the Rust tool root, which is the compiler sysroot. +pub(super) const REMAPPED_TOOLS: &str = "/rust-tools"; +/// Fixed prefix of the Cargo target directory, which holds every `OUT_DIR`. +pub(super) const REMAPPED_TARGET: &str = "/cargo-target"; +/// Every fixed prefix, in emitted order. +pub(super) const REMAPPED_PREFIXES: [&str; 5] = [ + REMAPPED_CHECKOUT, + REMAPPED_VENDOR, + REMAPPED_CARGO_HOME, + REMAPPED_TOOLS, + REMAPPED_TARGET, +]; +/// Execution-namespace prefix of every container mount; never in final bytes. +const EXECUTION_MOUNT_PREFIX: &str = "/boundary/"; +/// Most forbidden prefixes one scan accepts. +const MAX_FORBIDDEN: usize = 64; +/// Longest forbidden prefix one scan accepts. +const MAX_PREFIX_BYTES: usize = 4096; + +/// Execution paths of every root a compiled path can come from. +/// +/// Each must already be plain absolute text without quotes, backslashes, +/// `:` or control characters, as [`super::paths::execution_text`] returns. +pub(super) struct RemapSources<'a> { + pub(super) checkout: &'a str, + pub(super) vendor: &'a str, + pub(super) cargo_home: &'a str, + pub(super) tools: &'a str, + pub(super) target: &'a str, +} + +/// Returns the `--config` pair remapping every source root for `triple`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when two remapped roots are equal or +/// nested, since rustc's last-match-wins rule would then make the order of +/// the flags decide the compiled path. +pub(super) fn remap_arguments( + triple: &str, + sources: &RemapSources<'_>, +) -> Result, Report> { + remap_arguments_with(triple, sources, &[]) +} + +/// Returns the same `--config` pair with `extra` rustflags appended inside +/// the one target rustflags entry, such as the Darwin linker arguments. +/// +/// # Errors +/// +/// As [`remap_arguments`], or [`ReleaseError::Recipe`] when an extra flag +/// holds a quote, backslash or control character a TOML basic string would +/// need to escape. +pub(super) fn remap_arguments_with( + triple: &str, + sources: &RemapSources<'_>, + extra: &[String], +) -> Result, Report> { + if extra + .iter() + .any(|flag| flag.contains(['"', '\\']) || flag.chars().any(char::is_control)) + { + return Err(recipe_error( + "an extra rustflag holds a quote, backslash or control character", + )); + } + let flags: Vec = remapped_pairs(sources)? + .map(|(from, to)| format!("\"--remap-path-prefix={from}={to}\"")) + .chain(extra.iter().map(|flag| format!("\"{flag}\""))) + .collect(); + Ok(vec![ + "--config".to_owned(), + format!("target.\"{triple}\".rustflags=[{}]", flags.join(",")), + ]) +} + +/// Pairs every source root with its fixed prefix after requiring the roots +/// to be pairwise disjoint. +fn remapped_pairs<'a>( + sources: &RemapSources<'a>, +) -> Result, Report> { + let pairs = [ + ("remapped checkout", sources.checkout), + ("remapped vendor directory", sources.vendor), + ("remapped Cargo home", sources.cargo_home), + ("remapped Rust tools", sources.tools), + ("remapped target directory", sources.target), + ]; + let labelled: Vec<(&str, &Path)> = pairs + .iter() + .map(|(label, from)| (*label, Path::new(*from))) + .collect(); + disjoint(&labelled)?; + Ok(pairs + .into_iter() + .map(|(_, from)| from) + .zip(REMAPPED_PREFIXES)) +} + +/// Returns the target-scoped `CFLAGS` variable that applies the same remap to +/// C code, or `None` for Wasm. +/// +/// Native assets link C dependencies that `cc` compiles from build scripts +/// (bundled `SQLite`, `ring`, `aws-lc-sys`). With the profile's debug info, +/// Cargo tells those build scripts to emit DWARF, and `__FILE__` strings and +/// DWARF directories then hold execution paths rustc's remap never sees. +/// `-ffile-prefix-map` rewrites both in GCC 8+ and Clang 10+; +/// `-fdebug-prefix-map` is repeated because the GCC driver forwards only that +/// one to the assembler for `.S` sources. `cc` reads +/// `CFLAGS_` first, the plan sets it (the audited +/// environment rejects every inherited `CFLAGS` form), and it is bound by the +/// plan digest. `cc` splits the value on whitespace, so a root containing any +/// is refused instead of silently split. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when two roots overlap or a root holds +/// whitespace. +pub(super) fn c_prefix_map_environment( + target: ReleaseTarget, + sources: &RemapSources<'_>, +) -> Result, Report> { + c_prefix_map_environment_with(target, sources, None) +} + +/// Returns the same variable, also mapping the Apple developer directory to +/// [`REMAPPED_DEVELOPER`] when `developer` is given, so SDK header paths in +/// C debug info and `__FILE__` strings do not name the host Xcode. +/// +/// # Errors +/// +/// As [`c_prefix_map_environment`], with the developer directory included in +/// the overlap and whitespace checks. +pub(super) fn c_prefix_map_environment_with( + target: ReleaseTarget, + sources: &RemapSources<'_>, + developer: Option<&str>, +) -> Result, Report> { + if target == ReleaseTarget::Wasm32Wasip1 { + return Ok(None); + } + let mut pairs: Vec<(&str, &str)> = remapped_pairs(sources)?.collect(); + if let Some(developer) = developer { + let mut labelled: Vec<(&str, &Path)> = pairs + .iter() + .map(|(from, _)| ("remapped root", Path::new(*from))) + .collect(); + labelled.push(("remapped Apple developer directory", Path::new(developer))); + disjoint(&labelled)?; + pairs.push((developer, REMAPPED_DEVELOPER)); + } + let mut flags = Vec::new(); + for (from, to) in pairs { + if from.chars().any(char::is_whitespace) { + return Err(recipe_error( + "a remapped root holds whitespace, which cc would split", + )); + } + flags.push(format!("-ffile-prefix-map={from}={to}")); + flags.push(format!("-fdebug-prefix-map={from}={to}")); + } + Ok(Some(( + format!("CFLAGS_{}", target.triple().replace('-', "_")), + flags.join(" "), + ))) +} + +/// Requires the reviewed Cargo configuration not to set `build.rustflags`. +/// +/// The remap is a target rustflags entry, and Cargo ignores `build.rustflags` +/// whenever any target rustflags exist, so such a setting would be silently +/// dropped instead of reviewed. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the bytes are not UTF-8 TOML or set +/// `build.rustflags`. +pub(super) 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 the legacy extensionless `.cargo/config` at the checkout root. +/// +/// Cargo reads it beside `.cargo/config.toml`, so an unreviewed layer could +/// carry rustflags the remap check never sees. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the approved source tracks it, it +/// exists on disk in any form, or its absence cannot be established. +pub(super) 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}" + ))), + } +} + +/// Fixed prefixes found in final bytes that hold no forbidden path. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RemapScan { + found: Vec<&'static str>, +} + +impl RemapScan { + /// The fixed prefixes, each followed by `/`, found in the bytes. + pub(crate) fn found(&self) -> &[&'static str] { + &self.found + } +} + +/// Returns whether `needle` occurs in `haystack`. +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +/// Proves remapping on final bytes by substring search. +/// +/// The bytes must contain none of `forbidden` (host roots of the build) nor +/// the container mount prefix `/boundary/`, and at least one fixed prefix +/// followed by `/`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for more than 64 forbidden prefixes, an +/// empty or longer than 4096-byte prefix, any forbidden prefix found, or no +/// fixed prefix found. +pub(crate) fn scan_remapped_bytes( + bytes: &[u8], + forbidden: &[&str], +) -> Result> { + 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:?}" + ))); + } + } + let found: Vec<&'static str> = REMAPPED_PREFIXES + .into_iter() + .filter(|prefix| contains(bytes, format!("{prefix}/").as_bytes())) + .collect(); + if found.is_empty() { + return Err(recipe_error( + "final bytes hold no remapped path prefix, so remapping is unproven", + )); + } + Ok(RemapScan { found }) +} diff --git a/crates/trusted-server-release/src/recipe/runtime.rs b/crates/trusted-server-release/src/recipe/runtime.rs new file mode 100644 index 000000000..85fc8187a --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime.rs @@ -0,0 +1,594 @@ +//! Final-byte Fastly runtime acceptance through the pinned Viceroy. +//! +//! Before a final Fastly output is accepted, the gate validates the exact +//! bytes as a core Wasm Fastly command and then serves them with the pinned +//! Viceroy against a loopback publisher origin it starts itself. A publisher +//! request must return the origin's page, carrying this run's marker, with +//! exactly one injected Trusted Server script. The gate never invokes Cargo or +//! the Fastly CLI and never rewrites the module: Viceroy reads a private +//! read-only copy, and both the copy and the retained artifact must still +//! hash to the accepted digest afterwards. +//! +//! The settings-free `/health` route only gates readiness. Any rejection after +//! a successful health check (a failed publisher request, an unobserved +//! origin, an early Viceroy exit or a guest failure in its output) carries +//! [`HealthOnlySuccess`], so that outcome stays distinguishable from a +//! runtime that never started. +//! +//! The Viceroy and origin ports are chosen by binding an ephemeral loopback +//! port and releasing it, so another local process could take a port in that +//! window; such a race fails the gate, it cannot make it pass. + +mod config; +mod exchange; +mod export; +mod logs; +mod viceroy; + +use std::ffi::OsString; +use std::fs; +use std::io::Write as _; +use std::net::{Ipv4Addr, SocketAddr, TcpListener}; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::Path; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::compare::{FastlyEqualityChecked, read_artifact}; +use super::{RecipeCompleted, Role}; +use crate::error::ReleaseError; +use crate::inspect::inspect_wasm; +use crate::process::{BoundedCommand, Cancellation, ProcessLimits, ProcessOutcome}; +use crate::source::{ApprovedSource, CommitId}; +pub(crate) use exchange::PublisherObservation; +pub(crate) use logs::ViceroyOutput; +#[cfg(test)] +pub(crate) use viceroy::PINNED_VICEROY_VERSION; +pub(crate) use viceroy::SelectedViceroy; + +/// Wall-clock bound on the whole Viceroy run, compilation included. +const VICEROY_WALL: Duration = Duration::from_secs(15 * 60); +/// Bound on the time from spawn to a healthy runtime. +const STARTUP_DEADLINE: Duration = Duration::from_secs(10 * 60); +/// Interval between readiness probes. +const READINESS_POLL: Duration = Duration::from_millis(250); +/// Timeout of one readiness probe. +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +/// Timeout of the publisher request. +const PUBLISHER_TIMEOUT: Duration = Duration::from_secs(60); +/// Cap on each captured Viceroy output stream. +const VICEROY_OUTPUT_LIMIT: u64 = 8 * 1024 * 1024; +/// Headers of a top-level browser navigation. +const NAVIGATION_HEADERS: [(&str, &str); 4] = [ + ("Accept", "text/html,application/xhtml+xml"), + ("Sec-Fetch-Mode", "navigate"), + ("Sec-Fetch-Dest", "document"), + ("User-Agent", "trusted-server-release-runtime-gate"), +]; + +/// Typed reason attached to every [`ReleaseError::Runtime`] report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum RuntimeRejection { + /// The handed-off artifact is not the accepted, unchanged final output. + #[display("artifact is not the accepted final output")] + Artifact, + /// The Viceroy executable is unusable, unpinned or changed. + #[display("Viceroy selection failed")] + Viceroy, + /// The fixed fixture or its configuration could not be generated. + #[display("runtime fixture is unusable")] + Fixture, + /// Viceroy exited or never answered the health check in time. + #[display("runtime never became healthy")] + Startup, + /// A loopback HTTP exchange failed or did not parse. + #[display("HTTP exchange failed")] + Exchange, + /// The publisher page did not answer 200. + #[display("publisher page status is not 200")] + Status, + /// The Trusted Server script injection is missing or malformed. + #[display("Trusted Server script injection is missing or malformed")] + Injection, + /// The page does not carry this run's origin marker. + #[display("publisher page did not come from this run's origin")] + OriginMarker, + /// The origin fixture never received the publisher request. + #[display("origin fixture did not observe the publisher request")] + OriginUnobserved, + /// The executed copy changed during the run. + #[display("executed artifact changed during the run")] + Changed, + /// Viceroy ended other than by the gate's cancellation. + #[display("Viceroy did not end by the gate's cancellation")] + Supervision, + /// The captured Viceroy output reports a guest trap, panic or failed + /// request execution. + #[display("Viceroy output reports a guest failure")] + GuestFailure, +} + +/// Marks a rejection whose health check succeeded before the publisher +/// request failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +#[display("health check succeeded but publisher acceptance failed")] +pub(crate) struct HealthOnlySuccess; + +/// Builds a runtime rejection carrying `rejection` and a detail line. +fn runtime_error( + rejection: RuntimeRejection, + message: impl std::fmt::Display, +) -> Report { + Report::new(ReleaseError::Runtime) + .attach(rejection) + .attach(message.to_string()) +} + +/// Whether the gate serves the publisher origin. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum OriginPresence { + /// The origin fixture serves the publisher page. + Served, + /// The origin port is closed, so only `/health` can succeed. Tests use + /// it to prove that a health-only success is rejected. + #[cfg(test)] + Closed, +} + +/// Proof that the retained final Fastly output of an approved source passed +/// core-format validation and a publisher request through the pinned +/// Viceroy. +/// +/// It has private fields and only [`accept_fastly_runtime`] constructs it; +/// a bare [`run_gate`] observation is not acceptance. +#[derive(Debug)] +pub(crate) struct FastlyRuntimeAccepted { + source_commit: CommitId, + role: Role, + observed: GateObservation, +} + +impl FastlyRuntimeAccepted { + /// The approved source commit whose fixtures and output were accepted. + pub(crate) fn source_commit(&self) -> &CommitId { + &self.source_commit + } + + /// The build role whose final output was accepted. + pub(crate) fn role(&self) -> Role { + self.role + } + + /// What the gate run observed. + pub(crate) fn observed(&self) -> &GateObservation { + &self.observed + } +} + +/// What one successful gate run observed over exact final bytes. +/// +/// It has private fields and only [`run_gate`] constructs it. +#[derive(Debug)] +pub(crate) struct GateObservation { + artifact_sha256: String, + artifact_size: u64, + viceroy_version: String, + viceroy_sha256: String, + fixture_sha256: String, + config_sha256: String, + health_status: u16, + publisher: PublisherObservation, + origin_requests: Vec, + startup: Duration, + viceroy_outcome: ProcessOutcome, + viceroy_output: ViceroyOutput, +} + +impl GateObservation { + /// Digests and sanitized stderr tail of the captured Viceroy output. + pub(crate) fn viceroy_output(&self) -> &ViceroyOutput { + &self.viceroy_output + } + + /// SHA-256 of the executed final bytes, unchanged by the run. + pub(crate) fn artifact_sha256(&self) -> &str { + &self.artifact_sha256 + } + + /// Size of the executed final bytes. + pub(crate) fn artifact_size(&self) -> u64 { + self.artifact_size + } + + /// The reported Viceroy version. + pub(crate) fn viceroy_version(&self) -> &str { + &self.viceroy_version + } + + /// SHA-256 of the Viceroy executable. + pub(crate) fn viceroy_sha256(&self) -> &str { + &self.viceroy_sha256 + } + + /// SHA-256 binding the fixed template, app config and store ID. + pub(crate) fn fixture_sha256(&self) -> &str { + &self.fixture_sha256 + } + + /// SHA-256 of the generated Viceroy configuration of this run. + pub(crate) fn config_sha256(&self) -> &str { + &self.config_sha256 + } + + /// Status of the readiness health check. + pub(crate) fn health_status(&self) -> u16 { + self.health_status + } + + /// What the accepted publisher response showed. + pub(crate) fn publisher(&self) -> &PublisherObservation { + &self.publisher + } + + /// Request lines the origin fixture received. + pub(crate) fn origin_requests(&self) -> &[String] { + &self.origin_requests + } + + /// Time from spawn to a healthy runtime. + pub(crate) fn startup(&self) -> Duration { + self.startup + } + + /// How the Viceroy run ended; always the gate's cancellation. + pub(crate) fn viceroy_outcome(&self) -> ProcessOutcome { + self.viceroy_outcome + } +} + +/// Runs the runtime gate over the retained final Fastly output that passed +/// the canonical and reproduction equality check. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Runtime`] with [`RuntimeRejection::Artifact`] when +/// `completed` is not the checked output of `source` or no longer rehashes, +/// [`RuntimeRejection::Fixture`] when `source` does not track the embedded +/// fixtures unchanged, [`ReleaseError::Inspect`] when the bytes are not a +/// valid Fastly core command, and the gate's other rejections from +/// [`run_gate`]. +pub(crate) fn accept_fastly_runtime( + source: &ApprovedSource, + completed: &RecipeCompleted, + checked: &FastlyEqualityChecked, + viceroy: &SelectedViceroy, +) -> Result> { + if source.commit() != completed.commit() { + return Err(runtime_error( + RuntimeRejection::Artifact, + "artifact was not built from the supplied approved source", + )); + } + config::require_built_source_fixtures(&|path| source.tracked_sha256(path).map(str::to_owned))?; + let artifact = completed.artifact(); + if completed.asset() != checked.asset() + || artifact.sha256() != checked.sha256() + || artifact.size() != checked.size() + { + return Err(runtime_error( + RuntimeRejection::Artifact, + "artifact is not the output the equality check accepted", + )); + } + let bytes = read_artifact(completed).map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Artifact) + })?; + let observed = run_gate(&bytes, viceroy, OriginPresence::Served)?; + completed.rehash_for_handoff().map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Changed) + })?; + Ok(FastlyRuntimeAccepted { + source_commit: completed.commit().clone(), + role: completed.role(), + observed, + }) +} + +/// Validates `bytes` and serves them through `viceroy` against the origin. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Inspect`] for invalid core Wasm, a foreign host +/// ABI, a missing entry ABI or stripped debug sections; otherwise +/// [`ReleaseError::Runtime`] with a [`RuntimeRejection`]. Every rejection +/// after a healthy readiness check also carries [`HealthOnlySuccess`]. +pub(super) fn run_gate( + bytes: &[u8], + viceroy: &SelectedViceroy, + origin: OriginPresence, +) -> Result> { + let facts = inspect_wasm(bytes)?; + facts.require_fastly_entry_abi()?; + facts.require_debug_sections()?; + viceroy.reverify()?; + let private = tempfile::tempdir().map_err(|error| fixture_io("private directory", &error))?; + let wasm = private.path().join("final.wasm"); + write_private(&wasm, bytes, 0o400)?; + require_unchanged(&wasm, facts.sha256())?; + let marker = run_marker(facts.sha256()); + let origin_fixture = match origin { + OriginPresence::Served => Some(exchange::OriginFixture::start(&marker)?), + #[cfg(test)] + OriginPresence::Closed => None, + }; + let origin_address = match &origin_fixture { + Some(fixture) => fixture.address(), + None => free_loopback_address()?, + }; + let generated = config::viceroy_config(&format!("http://{origin_address}"))?; + let config_path = private.path().join("viceroy.toml"); + write_private(&config_path, generated.text.as_bytes(), 0o400)?; + let home = private.path().join("home"); + fs::create_dir(&home).map_err(|error| fixture_io("home directory", &error))?; + let address = free_loopback_address()?; + let run = serve(&ServeInputs { + viceroy, + wasm: &wasm, + config: &config_path, + home: &home, + working_directory: private.path(), + address, + marker: &marker, + }); + let origin_requests = origin_fixture + .map(exchange::OriginFixture::stop) + .unwrap_or_default(); + require_unchanged(&wasm, facts.sha256())?; + viceroy.reverify()?; + let served = run?; + if !origin_requests + .iter() + .any(|line| line.starts_with("GET / ")) + { + return Err(runtime_error( + RuntimeRejection::OriginUnobserved, + format!("origin fixture received {origin_requests:?}"), + ) + .attach(HealthOnlySuccess)); + } + Ok(GateObservation { + artifact_sha256: facts.sha256().to_owned(), + artifact_size: bytes.len() as u64, + viceroy_version: viceroy.version().to_owned(), + viceroy_sha256: viceroy.sha256().to_owned(), + fixture_sha256: generated.fixture_sha256, + config_sha256: generated.sha256, + health_status: served.health_status, + publisher: served.publisher, + origin_requests, + startup: served.startup, + viceroy_outcome: served.outcome, + viceroy_output: served.output, + }) +} + +/// Inputs of one Viceroy serving run. +struct ServeInputs<'a> { + viceroy: &'a SelectedViceroy, + wasm: &'a Path, + config: &'a Path, + home: &'a Path, + working_directory: &'a Path, + address: SocketAddr, + marker: &'a str, +} + +/// What one successful serving run observed. +struct Served { + health_status: u16, + publisher: PublisherObservation, + startup: Duration, + outcome: ProcessOutcome, + output: ViceroyOutput, +} + +/// Cancels the Viceroy run when dropped, so a panic while exchanging stops +/// Viceroy at once instead of waiting out [`VICEROY_WALL`] in the scope join. +struct CancelOnDrop<'a>(&'a Cancellation); + +impl Drop for CancelOnDrop<'_> { + fn drop(&mut self) { + self.0.cancel(); + } +} + +/// Runs Viceroy, waits for health, checks one publisher request and stops it. +fn serve(inputs: &ServeInputs<'_>) -> Result> { + let cancellation = Cancellation::default(); + let command = viceroy_command(inputs, &cancellation)?; + let started = Instant::now(); + std::thread::scope(|scope| { + let running = scope.spawn(|| command.run()); + let stop = CancelOnDrop(&cancellation); + let exchanged = exchange_while_running(inputs, started, &|| running.is_finished()); + drop(stop); + let result = running + .join() + .map_err(|_| { + runtime_error(RuntimeRejection::Supervision, "Viceroy supervisor panicked") + })? + .map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Supervision) + })?; + let (publisher, startup) = exchanged.map_err(|error| { + error.attach(format!( + "Viceroy {} after {:?}; stderr tail: {}", + result.outcome(), + result.elapsed(), + logs::sanitized_tail(result.stderr()) + )) + })?; + if result.outcome() != ProcessOutcome::Cancelled { + return Err(runtime_error( + RuntimeRejection::Supervision, + format!("Viceroy {} before the gate stopped it", result.outcome()), + ) + .attach(HealthOnlySuccess)); + } + let output = logs::scan_viceroy_output(result.stdout(), result.stderr()) + .map_err(|error| error.attach(HealthOnlySuccess))?; + Ok(Served { + health_status: 200, + publisher, + startup, + outcome: result.outcome(), + output, + }) + }) +} + +/// Builds the fixed Viceroy command line. +fn viceroy_command( + inputs: &ServeInputs<'_>, + cancellation: &Cancellation, +) -> Result> { + let arguments = [ + OsString::from("serve"), + OsString::from("--addr"), + OsString::from(inputs.address.to_string()), + OsString::from("--config"), + inputs.config.as_os_str().to_owned(), + OsString::from("--unknown-import-behavior"), + OsString::from("link-error"), + inputs.wasm.as_os_str().to_owned(), + ]; + BoundedCommand::new( + inputs.viceroy.path(), + inputs.working_directory, + ProcessLimits { + wall: VICEROY_WALL, + stdout_max: VICEROY_OUTPUT_LIMIT, + stderr_max: VICEROY_OUTPUT_LIMIT, + }, + ) + .and_then(|command| { + command + .arguments(arguments) + .environment("HOME", inputs.home) + }) + .map(|command| command.cancellation(cancellation.clone())) + .map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Supervision) + }) +} + +/// Waits for health, then sends and checks the publisher request. +fn exchange_while_running( + inputs: &ServeInputs<'_>, + started: Instant, + exited: &dyn Fn() -> bool, +) -> Result<(PublisherObservation, Duration), Report> { + loop { + if exited() { + return Err(runtime_error( + RuntimeRejection::Startup, + "Viceroy exited before answering the health check", + )); + } + if started.elapsed() > STARTUP_DEADLINE { + return Err(runtime_error( + RuntimeRejection::Startup, + "Viceroy did not answer the health check before the startup deadline", + )); + } + if exchange::get(inputs.address, "/health", &[], PROBE_TIMEOUT) + .is_ok_and(|response| exchange::is_health_ok(&response)) + { + break; + } + std::thread::sleep(READINESS_POLL); + } + let startup = started.elapsed(); + exchange::get(inputs.address, "/", &NAVIGATION_HEADERS, PUBLISHER_TIMEOUT) + .and_then(|response| exchange::check_publisher_response(&response, inputs.marker)) + .map(|publisher| (publisher, startup)) + .map_err(|error| error.attach(HealthOnlySuccess)) +} + +fn fixture_io(what: &str, error: &std::io::Error) -> Report { + runtime_error( + RuntimeRejection::Fixture, + format!("cannot create the {what}: {error}"), + ) +} + +/// Writes a new private file with `mode` and syncs it. +fn write_private(path: &Path, bytes: &[u8], mode: u32) -> Result<(), Report> { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(path) + .and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all())) + .map_err(|error| fixture_io("private file", &error)) +} + +/// Requires the file at `path` to hash to `sha256`. +fn require_unchanged(path: &Path, sha256: &str) -> Result<(), Report> { + let bytes = fs::read(path).map_err(|error| { + runtime_error( + RuntimeRejection::Changed, + format!("cannot reread the copy: {error}"), + ) + })?; + if hex::encode(Sha256::digest(&bytes)) != sha256 { + return Err(runtime_error( + RuntimeRejection::Changed, + "executed copy no longer hashes to the accepted digest", + )); + } + Ok(()) +} + +/// Returns a loopback address whose port was free a moment ago. +fn free_loopback_address() -> Result> { + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .and_then(|listener| listener.local_addr()) + .map_err(|error| fixture_io("loopback port", &error)) +} + +/// Returns a marker unique to this run, so a cached or foreign page fails. +fn run_marker(artifact_sha256: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let seed = format!("{artifact_sha256}:{nanos}:{}", std::process::id()); + let digest = hex::encode(Sha256::digest(seed.as_bytes())); + format!("trusted-server-release-origin-{}", &digest[..24]) +} + +#[cfg(test)] +pub(super) mod tests_support { + //! Narrow access to the gate's pure parts for the recipe tests. + + pub(in crate::recipe) use super::config::{ + app_config_data, canonical, config_store_id, envelope_json, require_built_source_fixtures, + viceroy_config, + }; + pub(in crate::recipe) use super::exchange::{ + HttpResponse, OriginFixture, check_publisher_response, get, is_health_ok, origin_page, + parse_response, + }; + pub(in crate::recipe) use super::logs::{sanitized_tail, scan_viceroy_output}; + pub(in crate::recipe) use super::viceroy::parse_version_output; +} diff --git a/crates/trusted-server-release/src/recipe/runtime/config.rs b/crates/trusted-server-release/src/recipe/runtime/config.rs new file mode 100644 index 000000000..ab6f3590d --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/config.rs @@ -0,0 +1,340 @@ +//! Fixed Viceroy local configuration for the final-byte runtime gate. +//! +//! The gate reuses the integration harness fixtures verbatim: its Viceroy +//! template (KV, secret and JWKS stores with generated test-only values) and +//! its readable Trusted Server app config. Like the harness generator, it +//! overrides only `publisher.origin_url`, wraps the app config in a version 1 +//! blob envelope and injects it into the template's single generated-store +//! marker under the config store ID the tracked `edgezero.toml` declares. +//! +//! The producer has no dependency on the core crate, so the app config is +//! converted from TOML to JSON here and its envelope digest is computed with +//! the envelope's canonical form: compact JSON with object keys sorted by +//! UTF-8 bytes. Floats and datetimes are rejected rather than canonicalized, +//! so the fixture can never depend on number formatting. +//! +//! # Embedded fixtures +//! +//! The three fixture files are compiled into the producer from the producer's +//! own source tree, not read from the source that was built. The producer may +//! be built from a different revision than the approved source, so +//! [`require_built_source_fixtures`] fails closed unless the built source +//! tracks each file at the exact embedded content. + +use std::fmt::Write as _; + +use error_stack::Report; +use serde_json::{Map, Value}; +use sha2::{Digest as _, Sha256}; + +use super::{RuntimeRejection, runtime_error}; +use crate::error::ReleaseError; + +/// The integration harness Viceroy template. +const VICEROY_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../trusted-server-integration-tests/fixtures/configs/viceroy-template.toml" +)); +/// The integration harness readable app config. +const APP_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml" +)); +/// The tracked `EdgeZero` manifest naming the default config store. +const EDGEZERO_MANIFEST: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../edgezero.toml")); +/// The single line the generated config store replaces. +const GENERATED_STORES_MARKER: &str = " # GENERATED_TRUSTED_SERVER_CONFIG_STORES"; +/// Fixed envelope timestamp, the same one the harness generator stamps. +const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; +/// The only blob envelope version the runtime accepts. +const ENVELOPE_VERSION: u64 = 1; + +/// Each embedded fixture with its repository-relative tracked path. +const EMBEDDED_FIXTURES: [(&str, &str); 3] = [ + ( + "crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml", + VICEROY_TEMPLATE, + ), + ( + "crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml", + APP_CONFIG, + ), + ("edgezero.toml", EDGEZERO_MANIFEST), +]; + +/// Requires the built source to track every embedded fixture unchanged. +/// +/// `tracked_sha256` returns the SHA-256 of a tracked path's content in the +/// built source, or `None` when the path is not tracked. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] naming the first fixture that is +/// untracked or differs from the built source. +pub(in crate::recipe) fn require_built_source_fixtures( + tracked_sha256: &dyn Fn(&str) -> Option, +) -> Result<(), Report> { + for (path, embedded) in EMBEDDED_FIXTURES { + let expected = sha256_hex(embedded.as_bytes()); + match tracked_sha256(path) { + Some(built) if built == expected => {} + Some(built) => { + return Err(runtime_error( + RuntimeRejection::Fixture, + format!( + "embedded fixture {path} differs from built source \ + (embedded {expected}, built {built})" + ), + )); + } + None => { + return Err(runtime_error( + RuntimeRejection::Fixture, + format!("embedded fixture {path} is not tracked by the built source"), + )); + } + } + } + Ok(()) +} + +/// One generated Viceroy configuration and the digests that identify it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::recipe) struct GeneratedConfig { + /// The complete Viceroy `local_server` configuration. + pub(in crate::recipe) text: String, + /// SHA-256 of [`Self::text`]. + pub(in crate::recipe) sha256: String, + /// SHA-256 binding the fixed template, app config and store ID. + pub(in crate::recipe) fixture_sha256: String, +} + +/// Returns the default config store ID declared by `edgezero.toml`. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] when the manifest does not parse or +/// the ID is missing or not a plain lowercase identifier. +pub(in crate::recipe) fn config_store_id() -> Result> { + let fixture = |message: &str| runtime_error(RuntimeRejection::Fixture, message); + let manifest: toml::Table = toml::from_str(EDGEZERO_MANIFEST) + .map_err(|error| fixture(&format!("edgezero.toml does not parse: {error}")))?; + let id = manifest + .get("stores") + .and_then(|stores| stores.get("config")) + .and_then(|config| config.get("default")) + .and_then(toml::Value::as_str) + .ok_or_else(|| fixture("edgezero.toml declares no default config store"))?; + let plain = !id.is_empty() + && id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'); + if !plain { + return Err(fixture("default config store ID is not a plain identifier")); + } + Ok(id.to_owned()) +} + +/// Converts a TOML value into JSON, rejecting floats and datetimes. +fn toml_to_json(value: &toml::Value) -> Result> { + Ok(match value { + toml::Value::String(text) => Value::String(text.clone()), + toml::Value::Integer(number) => Value::from(*number), + toml::Value::Boolean(flag) => Value::Bool(*flag), + toml::Value::Array(items) => { + Value::Array(items.iter().map(toml_to_json).collect::>()?) + } + toml::Value::Table(table) => table_to_json(table)?, + toml::Value::Float(_) | toml::Value::Datetime(_) => { + return Err(runtime_error( + RuntimeRejection::Fixture, + "app config fixture holds a float or datetime", + )); + } + }) +} + +fn table_to_json(table: &toml::Table) -> Result> { + let mut map = Map::new(); + for (key, value) in table { + map.insert(key.clone(), toml_to_json(value)?); + } + Ok(Value::Object(map)) +} + +/// Returns the integration app config as JSON with `origin_url` replaced. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] when the fixture does not parse, +/// holds a float or datetime, or lacks a string `publisher.origin_url`. +pub(in crate::recipe) fn app_config_data(origin_url: &str) -> Result> { + let table: toml::Table = toml::from_str(APP_CONFIG).map_err(|error| { + runtime_error( + RuntimeRejection::Fixture, + format!("app config fixture does not parse: {error}"), + ) + })?; + let mut data = table_to_json(&table)?; + let origin = data + .pointer_mut("/publisher/origin_url") + .filter(|value| value.is_string()) + .ok_or_else(|| { + runtime_error( + RuntimeRejection::Fixture, + "app config fixture lacks publisher.origin_url", + ) + })?; + *origin = Value::String(origin_url.to_owned()); + Ok(data) +} + +/// Writes `value` in the envelope's canonical form. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] for a non-integer number. +pub(in crate::recipe) fn write_canonical( + out: &mut String, + value: &Value, +) -> Result<(), Report> { + match value { + Value::Null => out.push_str("null"), + Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }), + Value::Number(number) => { + if let Some(integer) = number.as_i64() { + let _ = write!(out, "{integer}"); + } else if let Some(integer) = number.as_u64() { + let _ = write!(out, "{integer}"); + } else { + return Err(runtime_error( + RuntimeRejection::Fixture, + "canonical form rejects non-integer numbers", + )); + } + } + Value::String(text) => write_string(out, text), + Value::Array(items) => { + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); + } + write_canonical(out, item)?; + } + out.push(']'); + } + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_unstable_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + out.push('{'); + for (index, key) in keys.into_iter().enumerate() { + if index > 0 { + out.push(','); + } + write_string(out, key); + out.push(':'); + write_canonical(out, &map[key])?; + } + out.push('}'); + } + } + Ok(()) +} + +/// Writes a JSON string with the envelope's fixed escape table. +fn write_string(out: &mut String, text: &str) { + out.push('"'); + for character in text.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{0008}' => out.push_str("\\b"), + '\u{000C}' => out.push_str("\\f"), + control if u32::from(control) < 0x20 => { + let _ = write!(out, "\\u{:04x}", u32::from(control)); + } + other => out.push(other), + } + } + out.push('"'); +} + +/// Returns the canonical form of `value`. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] for a non-integer number. +pub(in crate::recipe) fn canonical(value: &Value) -> Result> { + let mut out = String::new(); + write_canonical(&mut out, value)?; + Ok(out) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Returns the blob envelope JSON wrapping `data`. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] for a non-integer number. +pub(in crate::recipe) fn envelope_json(data: &Value) -> Result> { + let sha256 = sha256_hex(canonical(data)?.as_bytes()); + canonical(&serde_json::json!({ + "data": data, + "generated_at": GENERATED_AT, + "sha256": sha256, + "version": ENVELOPE_VERSION, + })) +} + +/// Builds the complete Viceroy configuration for `origin_url`. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] when the template does not hold +/// exactly one generated-store marker, the store ID or app config is +/// unusable, or the envelope cannot be embedded as a TOML literal string. +pub(in crate::recipe) fn viceroy_config( + origin_url: &str, +) -> Result> { + let markers = VICEROY_TEMPLATE.matches(GENERATED_STORES_MARKER).count(); + if markers != 1 { + return Err(runtime_error( + RuntimeRejection::Fixture, + format!("Viceroy template holds {markers} generated-store markers, not one"), + )); + } + let store = config_store_id()?; + let envelope = envelope_json(&app_config_data(origin_url)?)?; + if envelope.contains("'''") { + return Err(runtime_error( + RuntimeRejection::Fixture, + "envelope cannot be embedded as a TOML literal string", + )); + } + let block = format!( + " [local_server.config_stores.{store}]\n format = \"inline-toml\"\n \ + [local_server.config_stores.{store}.contents]\n {store} = '''{envelope}'''" + ); + let text = VICEROY_TEMPLATE.replace(GENERATED_STORES_MARKER, &block); + let fixture_sha256 = sha256_hex( + format!( + "viceroy-template.toml {}\ntrusted-server.integration.toml {}\nconfig-store {store}\n", + sha256_hex(VICEROY_TEMPLATE.as_bytes()), + sha256_hex(APP_CONFIG.as_bytes()), + ) + .as_bytes(), + ); + Ok(GeneratedConfig { + sha256: sha256_hex(text.as_bytes()), + text, + fixture_sha256, + }) +} diff --git a/crates/trusted-server-release/src/recipe/runtime/exchange.rs b/crates/trusted-server-release/src/recipe/runtime/exchange.rs new file mode 100644 index 000000000..32db26fa1 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/exchange.rs @@ -0,0 +1,461 @@ +//! Loopback HTTP exchanges of the runtime gate: the publisher origin fixture, +//! a minimal HTTP/1.1 client and the pure response checks. +//! +//! The client only speaks to loopback addresses the gate chose itself. It +//! requests `identity` encoding with `Connection: close`, reads at most +//! [`RESPONSE_LIMIT`] bytes and decodes `Content-Length` or chunked bodies +//! strictly. + +use std::io::{ErrorKind, Read as _, Write as _}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::{RuntimeRejection, runtime_error}; +use crate::error::ReleaseError; + +/// Maximum bytes read from one response, head and body together. +pub(in crate::recipe) const RESPONSE_LIMIT: u64 = 4 * 1024 * 1024; +/// Maximum bytes the origin fixture reads from one request head. +const REQUEST_HEAD_LIMIT: u64 = 64 * 1024; +/// The attribute that identifies the injected Trusted Server script. +const SCRIPT_ID: &str = "id=\"trustedserver-js\""; +/// The required prefix of the injected script's `src`. +const SCRIPT_SOURCE_PREFIX: &str = "/static/tsjs="; +/// Interval at which the origin fixture checks its stop flag. +const ACCEPT_POLL: Duration = Duration::from_millis(20); + +/// A parsed HTTP response with a fully decoded body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::recipe) struct HttpResponse { + pub(in crate::recipe) status: u16, + /// Header names in lowercase, in received order. + pub(in crate::recipe) headers: Vec<(String, String)>, + pub(in crate::recipe) body: Vec, +} + +impl HttpResponse { + /// Returns the first value of `name`, compared case-insensitively. + pub(in crate::recipe) fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(seen, _)| seen.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } +} + +/// What the accepted publisher response showed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PublisherObservation { + pub(crate) status: u16, + pub(crate) body_size: usize, + pub(crate) body_sha256: String, + pub(crate) script_src: String, +} + +fn exchange_error(message: impl std::fmt::Display) -> Report { + runtime_error(RuntimeRejection::Exchange, message) +} + +/// Sends one `GET` to a loopback `address` and parses the response. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Exchange`] when the connection, write or read +/// fails or times out, the response exceeds [`RESPONSE_LIMIT`], or it does +/// not parse. +pub(in crate::recipe) fn get( + address: SocketAddr, + path: &str, + headers: &[(&str, &str)], + timeout: Duration, +) -> Result> { + if !address.ip().is_loopback() { + return Err(exchange_error("the gate only contacts loopback addresses")); + } + let mut stream = TcpStream::connect_timeout(&address, timeout) + .map_err(|error| exchange_error(format!("cannot connect to {address}: {error}")))?; + stream + .set_read_timeout(Some(timeout)) + .and_then(|()| stream.set_write_timeout(Some(timeout))) + .map_err(|error| exchange_error(format!("cannot set socket timeouts: {error}")))?; + let mut request = format!( + "GET {path} HTTP/1.1\r\nHost: {address}\r\nAccept-Encoding: identity\r\nConnection: close\r\n" + ); + for (name, value) in headers { + request.push_str(&format!("{name}: {value}\r\n")); + } + request.push_str("\r\n"); + stream + .write_all(request.as_bytes()) + .map_err(|error| exchange_error(format!("cannot send the request: {error}")))?; + let mut raw = Vec::new(); + stream + .take(RESPONSE_LIMIT + 1) + .read_to_end(&mut raw) + .map_err(|error| exchange_error(format!("cannot read the response: {error}")))?; + if raw.len() as u64 > RESPONSE_LIMIT { + return Err(exchange_error("response exceeds the byte limit")); + } + parse_response(&raw) +} + +/// Parses a complete HTTP/1.x response read until the connection closed. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Exchange`] for a malformed head or header +/// name, a non-identity content encoding, a transfer encoding other than +/// exactly `chunked`, duplicate or conflicting framing headers, a body +/// shorter or longer than its `Content-Length`, or a malformed chunked body. +pub(in crate::recipe) fn parse_response(raw: &[u8]) -> Result> { + let split = raw + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or_else(|| exchange_error("response head is incomplete"))?; + let head = std::str::from_utf8(&raw[..split]) + .ok() + .filter(|head| head.is_ascii()) + .ok_or_else(|| exchange_error("response head is not ASCII"))?; + let rest = &raw[split + 4..]; + let mut lines = head.split("\r\n"); + let status = parse_status_line(lines.next().unwrap_or_default())?; + let headers = lines + .map(parse_header_line) + .collect::, _>>()?; + let mut response = HttpResponse { + status, + headers, + body: Vec::new(), + }; + if let Some(encoding) = response.header("content-encoding") + && !encoding.eq_ignore_ascii_case("identity") + { + return Err(exchange_error(format!( + "unexpected content encoding {encoding:?}" + ))); + } + let count = |name: &str| { + response + .headers + .iter() + .filter(|(seen, _)| seen == name) + .count() + }; + let transfer_encodings = count("transfer-encoding"); + let content_lengths = count("content-length"); + if transfer_encodings > 1 || content_lengths > 1 { + return Err(exchange_error("duplicate framing header")); + } + if transfer_encodings == 1 && content_lengths == 1 { + return Err(exchange_error( + "transfer encoding and content length are both present", + )); + } + response.body = if let Some(encoding) = response.header("transfer-encoding") { + if !encoding.eq_ignore_ascii_case("chunked") { + return Err(exchange_error(format!( + "unsupported transfer encoding {encoding:?}" + ))); + } + decode_chunked(rest)? + } else if let Some(length) = response.header("content-length") { + let length: usize = length + .bytes() + .all(|byte| byte.is_ascii_digit()) + .then(|| length.parse().ok()) + .flatten() + .ok_or_else(|| exchange_error(format!("malformed content length {length:?}")))?; + if rest.len() != length { + return Err(exchange_error(format!( + "body holds {} bytes, content length says {length}", + rest.len() + ))); + } + rest.to_vec() + } else { + rest.to_vec() + }; + Ok(response) +} + +/// Whether `byte` may appear in an HTTP header name (an RFC 9110 token). +fn is_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte) +} + +/// Parses one `name: value` header line, lowercasing the name. +fn parse_header_line(line: &str) -> Result<(String, String), Report> { + line.split_once(':') + .filter(|(name, _)| !name.is_empty() && name.bytes().all(is_token_byte)) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_owned())) + .ok_or_else(|| exchange_error(format!("malformed header line {line:?}"))) +} + +/// Parses `HTTP/1.x NNN reason` into its status code. +fn parse_status_line(line: &str) -> Result> { + let mut parts = line.splitn(3, ' '); + let version = parts.next().unwrap_or_default(); + let code = parts.next().unwrap_or_default(); + if !matches!(version, "HTTP/1.1" | "HTTP/1.0") + || code.len() != 3 + || !code.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(exchange_error(format!("malformed status line {line:?}"))); + } + code.parse() + .map_err(|_| exchange_error(format!("malformed status line {line:?}"))) +} + +/// Decodes a complete chunked body, ignoring chunk extensions. +/// +/// The last chunk must be exactly `0\r\n\r\n` with nothing after it, so +/// trailers and trailing bytes are rejected. +fn decode_chunked(mut rest: &[u8]) -> Result, Report> { + let mut body = Vec::new(); + loop { + let line_end = rest + .windows(2) + .position(|window| window == b"\r\n") + .ok_or_else(|| exchange_error("chunk size line is incomplete"))?; + let size_text = std::str::from_utf8(&rest[..line_end]) + .ok() + .map(|line| line.split(';').next().unwrap_or_default()) + .filter(|size| { + !size.is_empty() && size.len() <= 8 && size.bytes().all(|b| b.is_ascii_hexdigit()) + }) + .ok_or_else(|| exchange_error("malformed chunk size"))?; + let size = usize::from_str_radix(size_text, 16) + .map_err(|_| exchange_error(format!("malformed chunk size {size_text:?}")))?; + rest = &rest[line_end + 2..]; + if size == 0 { + if rest != b"\r\n" { + return Err(exchange_error( + "last chunk is not followed by exactly one empty line", + )); + } + return Ok(body); + } + let data = rest + .get(..size) + .filter(|_| rest.get(size..size + 2) == Some(b"\r\n")) + .ok_or_else(|| exchange_error("chunk data is truncated"))?; + body.extend_from_slice(data); + rest = &rest[size + 2..]; + } +} + +/// Returns whether `response` is the settings-free health answer. +pub(in crate::recipe) fn is_health_ok(response: &HttpResponse) -> bool { + response.status == 200 && response.body.trim_ascii() == b"ok" +} + +/// Requires a successful publisher page proxied from this run's origin with +/// exactly one injected Trusted Server script ahead of the origin content. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Status`] for any status but 200, +/// [`RuntimeRejection::Injection`] for a non-HTML or non-UTF-8 page or a +/// missing, duplicated, malformed or misplaced script, and +/// [`RuntimeRejection::OriginMarker`] when the page does not hold this run's +/// origin marker exactly once. +pub(in crate::recipe) fn check_publisher_response( + response: &HttpResponse, + marker: &str, +) -> Result> { + if response.status != 200 { + return Err(runtime_error( + RuntimeRejection::Status, + format!("publisher page answered {}", response.status), + )); + } + let injection = |message: &str| runtime_error(RuntimeRejection::Injection, message); + if !response + .header("content-type") + .is_some_and(|value| value.to_ascii_lowercase().starts_with("text/html")) + { + return Err(injection("publisher page is not text/html")); + } + let body = std::str::from_utf8(&response.body) + .map_err(|_| injection("publisher page is not UTF-8"))?; + if body.matches(marker).count() != 1 { + return Err(runtime_error( + RuntimeRejection::OriginMarker, + "publisher page does not hold this run's origin marker exactly once", + )); + } + let scripts = body.matches(SCRIPT_ID).count(); + if scripts != 1 { + return Err(injection(&format!( + "publisher page holds {scripts} Trusted Server scripts, not one" + ))); + } + let id_at = body.find(SCRIPT_ID).unwrap_or_default(); + let open = body[..id_at] + .rfind("')) + .ok_or_else(|| injection("Trusted Server script id is not on a script tag"))?; + let close = body[id_at..] + .find('>') + .map(|offset| id_at + offset) + .ok_or_else(|| injection("Trusted Server script tag is unterminated"))?; + let source = body[open..close] + .split_once("src=\"") + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(source, _)| source) + .filter(|source| source.starts_with(SCRIPT_SOURCE_PREFIX)) + .ok_or_else(|| injection("Trusted Server script does not load the tsjs bundle"))?; + if body.find(marker).is_some_and(|marker_at| marker_at < open) { + return Err(injection( + "Trusted Server script follows the origin content", + )); + } + Ok(PublisherObservation { + status: response.status, + body_size: response.body.len(), + body_sha256: hex::encode(Sha256::digest(&response.body)), + script_src: source.to_owned(), + }) +} + +/// The publisher page the origin fixture serves for `marker`. +pub(in crate::recipe) fn origin_page(marker: &str) -> String { + format!( + "\n\ + Example publisher\n

Example publisher

\ +

{marker}

\n" + ) +} + +/// A loopback publisher origin that serves [`origin_page`] for `GET /` and +/// records every request line it receives. +pub(in crate::recipe) struct OriginFixture { + address: SocketAddr, + stop: Arc, + requests: Arc>>, + thread: Option>, +} + +impl OriginFixture { + /// Binds an ephemeral loopback port and starts serving. + /// + /// # Errors + /// + /// Returns [`RuntimeRejection::Fixture`] when the listener cannot be + /// bound or configured. + pub(in crate::recipe) fn start(marker: &str) -> Result> { + let fixture = |error: std::io::Error| { + runtime_error( + RuntimeRejection::Fixture, + format!("cannot start the origin fixture: {error}"), + ) + }; + let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(fixture)?; + listener.set_nonblocking(true).map_err(fixture)?; + let address = listener.local_addr().map_err(fixture)?; + let stop = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(Mutex::new(Vec::new())); + let page = origin_page(marker); + let thread = { + let stop = Arc::clone(&stop); + let requests = Arc::clone(&requests); + std::thread::spawn(move || serve(&listener, &stop, &requests, &page)) + }; + Ok(Self { + address, + stop, + requests, + thread: Some(thread), + }) + } + + /// The bound loopback address. + pub(in crate::recipe) fn address(&self) -> SocketAddr { + self.address + } + + /// Stops serving and returns every request line received. + pub(in crate::recipe) fn stop(mut self) -> Vec { + self.halt(); + self.requests + .lock() + .map(|requests| requests.clone()) + .unwrap_or_default() + } + + fn halt(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +impl Drop for OriginFixture { + fn drop(&mut self) { + self.halt(); + } +} + +/// Accepts connections until `stop` is set, answering each in turn. +fn serve(listener: &TcpListener, stop: &AtomicBool, requests: &Mutex>, page: &str) { + while !stop.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _)) => { + if let Some(line) = answer(stream, page) + && let Ok(mut requests) = requests.lock() + { + requests.push(line); + } + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + std::thread::sleep(ACCEPT_POLL); + } + Err(_) => std::thread::sleep(ACCEPT_POLL), + } + } +} + +/// Reads one request head and answers it; returns its request line. +fn answer(mut stream: TcpStream, page: &str) -> Option { + stream.set_nonblocking(false).ok()?; + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .ok()?; + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .ok()?; + let mut head = Vec::new(); + let mut limited = (&stream).take(REQUEST_HEAD_LIMIT); + let mut byte = [0_u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + match limited.read(&mut byte) { + Ok(1) => head.push(byte[0]), + _ => return None, + } + } + let line = String::from_utf8_lossy(&head) + .lines() + .next() + .unwrap_or_default() + .to_owned(); + let (status, content_type, body) = if line.starts_with("GET / ") { + ("200 OK", "text/html; charset=utf-8", page) + } else { + ("404 Not Found", "text/plain; charset=utf-8", "not found\n") + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\ + Connection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + Some(line) +} diff --git a/crates/trusted-server-release/src/recipe/runtime/export.rs b/crates/trusted-server-release/src/recipe/runtime/export.rs new file mode 100644 index 000000000..df78b329e --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/export.rs @@ -0,0 +1,92 @@ +//! Checked diagnostic runtime export; it cannot reconstruct acceptance from JSON. + +use error_stack::Report; +use serde_json::{Value, json}; + +use super::{FastlyRuntimeAccepted, RuntimeRejection, runtime_error}; +use crate::documents::{Role as DocumentRole, canonical_json}; +use crate::error::ReleaseError; +use crate::recipe::compare::FASTLY_ASSET; +use crate::recipe::{AssetId, RecipeCompleted, Role}; +use crate::source::CommitId; + +impl FastlyRuntimeAccepted { + /// Exports accepted runtime facts bound to this exact source, role and artifact. + /// + /// The immutable owning result survives build/runtime working-root cleanup. + /// Its diagnostic representation grants no qualification or signing authority. + /// + /// # Errors + /// + /// Rejects changed completed outputs, another source/role/artifact or a record + /// exceeding the metadata bound. JSON cannot construct runtime acceptance. + pub(crate) fn diagnostic_for( + &self, + completed: &RecipeCompleted, + ) -> Result> { + completed.rehash_for_handoff()?; + self.check_export_binding( + completed.commit(), + completed.role(), + completed.asset(), + completed.artifact().sha256(), + completed.artifact().size(), + )?; + let record = self.diagnostic_record()?; + completed.rehash_for_handoff()?; + Ok(record) + } + + fn check_export_binding( + &self, + commit: &CommitId, + role: Role, + asset: AssetId, + digest: &str, + size: u64, + ) -> Result<(), Report> { + if self.source_commit() != commit + || self.role != role + || asset.artifact().name != FASTLY_ASSET + || self.observed().artifact_sha256() != digest + || self.observed().artifact_size() != size + { + return Err(runtime_error( + RuntimeRejection::Artifact, + "runtime acceptance belongs to another source, role or final artifact", + )); + } + Ok(()) + } + + fn diagnostic_record(&self) -> Result> { + let observed = self.observed(); + let publisher = observed.publisher(); + let output = observed.viceroy_output(); + let startup = u64::try_from(observed.startup().as_millis()) + .map_err(|error| runtime_error(RuntimeRejection::Startup, error))?; + let record = json!({ + "domain": "trusted-server.release.fastly-runtime-diagnostic", + "schema_version": 1, "release_qualification": false, + "source_commit": self.source_commit().as_str(), "role": self.role.spec_label(), + "asset": FASTLY_ASSET, + "artifact": {"sha256": observed.artifact_sha256(), "size": observed.artifact_size()}, + "core_format": "core-wasm-fastly-command", + "viceroy": {"version": observed.viceroy_version(), "sha256": observed.viceroy_sha256()}, + "fixture_sha256": observed.fixture_sha256(), "config_sha256": observed.config_sha256(), + "health_status": observed.health_status(), + "publisher": {"status": publisher.status, "body_size": publisher.body_size, + "body_sha256": publisher.body_sha256, "script_src": publisher.script_src}, + "origin_requests": observed.origin_requests(), "startup_milliseconds": startup, + "viceroy_outcome": observed.viceroy_outcome().to_string(), + "viceroy_output": {"stdout_sha256": output.stdout_sha256, + "stderr_sha256": output.stderr_sha256, "stderr_tail": output.stderr_tail}, + }); + canonical_json(&record, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Runtime))?; + Ok(record) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/runtime/export/tests.rs b/crates/trusted-server-release/src/recipe/runtime/export/tests.rs new file mode 100644 index 000000000..3603f1962 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/export/tests.rs @@ -0,0 +1,126 @@ +use std::time::Duration; + +use super::super::{FastlyRuntimeAccepted, GateObservation, PublisherObservation, ViceroyOutput}; +use crate::process::ProcessOutcome; +use crate::recipe::{AssetId, Role}; +use crate::source::CommitId; + +// A fictional observation used only to exercise export joins and formatting. +// Production acceptance is still minted only by the actual final-byte gate. +fn accepted() -> FastlyRuntimeAccepted { + FastlyRuntimeAccepted { + source_commit: CommitId::parse(&"a".repeat(40)).expect("should parse fictional source"), + role: Role::Canonical, + observed: GateObservation { + artifact_sha256: "b".repeat(64), + artifact_size: 12, + viceroy_version: "0.21.0".to_owned(), + viceroy_sha256: "c".repeat(64), + fixture_sha256: "d".repeat(64), + config_sha256: "e".repeat(64), + health_status: 200, + publisher: PublisherObservation { + status: 200, + body_size: 42, + body_sha256: "f".repeat(64), + script_src: "/static/tsjs=example".to_owned(), + }, + origin_requests: vec!["GET / HTTP/1.1".to_owned()], + startup: Duration::from_millis(1234), + viceroy_outcome: ProcessOutcome::Cancelled, + viceroy_output: ViceroyOutput { + stdout_sha256: "1".repeat(64), + stderr_sha256: "2".repeat(64), + stderr_tail: "fixture output".to_owned(), + }, + }, + } +} + +#[test] +fn runtime_export_rejects_another_source_role_asset_digest_or_size() { + let accepted = accepted(); + let source = accepted.source_commit(); + let fastly = AssetId::parse("trusted-server-fastly.wasm").expect("should parse Fastly asset"); + assert!( + accepted + .check_export_binding(source, Role::Canonical, fastly, &"b".repeat(64), 12) + .is_ok(), + "should accept the exact runtime build binding" + ); + for (commit, role, asset, digest, size) in [ + ( + CommitId::parse(&"f".repeat(40)).expect("should parse different source"), + Role::Canonical, + fastly, + "b".repeat(64), + 12, + ), + ( + source.clone(), + Role::Reproduction, + fastly, + "b".repeat(64), + 12, + ), + ( + source.clone(), + Role::Canonical, + AssetId::parse("ts-x86_64-unknown-linux-gnu").expect("should parse native asset"), + "b".repeat(64), + 12, + ), + (source.clone(), Role::Canonical, fastly, "c".repeat(64), 12), + (source.clone(), Role::Canonical, fastly, "b".repeat(64), 13), + ] { + assert!( + accepted + .check_export_binding(&commit, role, asset, &digest, size) + .is_err(), + "should reject a runtime acceptance attached to another build" + ); + } +} + +#[test] +fn runtime_export_preserves_actual_gate_facts_and_integer_duration() { + let accepted = accepted(); + let record = accepted + .diagnostic_record() + .expect("should export checked diagnostic facts"); + assert_eq!(record["role"], "A", "should retain the accepted build role"); + assert_eq!( + record["viceroy"]["sha256"], + "c".repeat(64), + "should retain actual runtime executable identity" + ); + assert_eq!( + record["publisher"]["body_sha256"], + "f".repeat(64), + "should retain accepted publisher body digest" + ); + assert_eq!( + record["startup_milliseconds"], 1234, + "should avoid lossy floating-point duration" + ); + assert_eq!( + record["release_qualification"], false, + "should grant no release authority" + ); +} + +#[test] +fn runtime_export_rejects_oversized_records_and_duration_overflow() { + let mut oversized = accepted(); + oversized.observed.origin_requests = vec!["x".repeat(1024 * 1024)]; + assert!( + oversized.diagnostic_record().is_err(), + "should bound retained runtime diagnostic bytes" + ); + let mut duration = accepted(); + duration.observed.startup = Duration::MAX; + assert!( + duration.diagnostic_record().is_err(), + "should reject integer duration overflow" + ); +} diff --git a/crates/trusted-server-release/src/recipe/runtime/logs.rs b/crates/trusted-server-release/src/recipe/runtime/logs.rs new file mode 100644 index 000000000..4a02258da --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/logs.rs @@ -0,0 +1,98 @@ +//! Scan of the captured Viceroy output for guest failures. +//! +//! A guest trap or panic can still leave a valid-looking response on the +//! wire, so after the run the gate searches both captured streams (each +//! bounded by the command's output cap) for failure markers that Viceroy or +//! the Rust runtime print. Matching ignores ASCII case. The streams are +//! retained only as digests plus a short sanitized stderr tail. + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::{RuntimeRejection, runtime_error}; +use crate::error::ReleaseError; + +/// Lowercase markers of a guest trap, panic or failed request execution. +const GUEST_FAILURE_MARKERS: [&str; 7] = [ + "wasm trap", + "wasm backtrace", + "panicked at", + "error while executing", + "there was an error handling the request", + "error handling request", + "guest exited with", +]; +/// Bytes of stderr kept in the summary. +const TAIL_BYTES: usize = 1024; + +/// Digests and a short tail of the captured Viceroy output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ViceroyOutput { + pub(crate) stdout_sha256: String, + pub(crate) stderr_sha256: String, + /// The last stderr bytes, lossily decoded with control characters other + /// than newline replaced by `?`. + pub(crate) stderr_tail: String, +} + +/// Returns the last [`TAIL_BYTES`] of `stderr` as printable text. +pub(in crate::recipe) fn sanitized_tail(stderr: &[u8]) -> String { + let start = stderr.len().saturating_sub(TAIL_BYTES); + String::from_utf8_lossy(&stderr[start..]) + .chars() + .map(|character| { + if character.is_control() && character != '\n' { + '?' + } else { + character + } + }) + .collect() +} + +/// Rejects output carrying a guest failure marker and summarizes the rest. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::GuestFailure`] for a stderr line beginning +/// with `Error:` or, naming the stream and marker, for the first failure +/// marker found in either stream. +pub(in crate::recipe) fn scan_viceroy_output( + stdout: &[u8], + stderr: &[u8], +) -> Result> { + // A clean run leaves stderr empty; Viceroy reports a failed guest request + // there as an `Error: ...` line. + if String::from_utf8_lossy(stderr) + .lines() + .any(|line| line.trim_start().to_ascii_lowercase().starts_with("error:")) + { + return Err(runtime_error( + RuntimeRejection::GuestFailure, + format!( + "Viceroy stderr reports an error line; stderr tail: {}", + sanitized_tail(stderr) + ), + )); + } + for (stream, bytes) in [("stdout", stdout), ("stderr", stderr)] { + let text = String::from_utf8_lossy(bytes).to_ascii_lowercase(); + if let Some(marker) = GUEST_FAILURE_MARKERS + .iter() + .find(|marker| text.contains(*marker)) + { + return Err(runtime_error( + RuntimeRejection::GuestFailure, + format!( + "Viceroy {stream} reports {marker:?}; stderr tail: {}", + sanitized_tail(stderr) + ), + )); + } + } + Ok(ViceroyOutput { + stdout_sha256: hex::encode(Sha256::digest(stdout)), + stderr_sha256: hex::encode(Sha256::digest(stderr)), + stderr_tail: sanitized_tail(stderr), + }) +} diff --git a/crates/trusted-server-release/src/recipe/runtime/viceroy.rs b/crates/trusted-server-release/src/recipe/runtime/viceroy.rs new file mode 100644 index 000000000..9b226a46b --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/viceroy.rs @@ -0,0 +1,160 @@ +//! Explicit selection of the pinned Viceroy executable. +//! +//! The gate never searches `PATH`: the caller names one absolute regular +//! file. Selection hashes it, requires `--version` to report exactly +//! [`PINNED_VICEROY_VERSION`], and rehashes it so the reported version and +//! the recorded digest describe the same bytes. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use error_stack::Report; + +use super::{RuntimeRejection, runtime_error}; +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessLimits}; + +/// The Viceroy version pinned by `.tool-versions`. +pub(crate) const PINNED_VICEROY_VERSION: &str = "0.21.0"; +/// Upper bound on the Viceroy executable size. +const VICEROY_LIMIT: u64 = 512 * 1024 * 1024; + +/// A selected, version-checked and hashed Viceroy executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SelectedViceroy { + path: PathBuf, + version: String, + sha256: String, +} + +fn selection_error(message: impl std::fmt::Display) -> Report { + runtime_error(RuntimeRejection::Viceroy, message) +} + +impl SelectedViceroy { + /// Selects the Viceroy executable at the absolute `path`. + /// + /// # Errors + /// + /// Returns [`RuntimeRejection::Viceroy`] when `path` is not absolute, is + /// not a regular file, cannot be hashed, does not report exactly the + /// pinned version, or changes while it is checked. + pub(crate) fn select(path: &Path) -> Result> { + if !path.is_absolute() { + return Err(selection_error("Viceroy path is not absolute")); + } + let metadata = std::fs::symlink_metadata(path) + .map_err(|error| selection_error(format!("cannot inspect Viceroy: {error}")))?; + if !metadata.is_file() { + return Err(selection_error("Viceroy is not a regular file")); + } + let sha256 = digest(path)?; + let working_directory = path + .parent() + .ok_or_else(|| selection_error("Viceroy path has no parent directory"))?; + let stdout = BoundedCommand::new( + path, + working_directory, + ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 4096, + stderr_max: 4096, + }, + ) + .map_err(|error| error.change_context(ReleaseError::Runtime))? + .arguments([OsString::from("--version")]) + .run() + .and_then(crate::process::ProcessResult::require_success) + .map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Viceroy) + })?; + let version = parse_version_output(&stdout)?; + let selected = Self { + path: path.to_owned(), + version, + sha256, + }; + selected.reverify()?; + Ok(selected) + } + + /// The selected absolute path. + pub(crate) fn path(&self) -> &Path { + &self.path + } + + /// The reported version, equal to [`PINNED_VICEROY_VERSION`]. + pub(crate) fn version(&self) -> &str { + &self.version + } + + /// SHA-256 of the selected executable. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Requires the executable to still hash to the recorded digest. + /// + /// # Errors + /// + /// Returns [`RuntimeRejection::Viceroy`] when it cannot be hashed or its + /// bytes changed. + pub(crate) fn reverify(&self) -> Result<(), Report> { + if digest(&self.path)? != self.sha256 { + return Err(selection_error( + "Viceroy executable changed after selection", + )); + } + Ok(()) + } +} + +#[cfg(test)] +impl SelectedViceroy { + /// A selection that was never version-checked, for tests that must be + /// rejected before Viceroy would run. + pub(crate) fn unverified_for_tests(path: &Path, sha256: &str) -> Self { + Self { + path: path.to_owned(), + version: PINNED_VICEROY_VERSION.to_owned(), + sha256: sha256.to_owned(), + } + } +} + +fn digest(path: &Path) -> Result> { + hash_bounded_with_check(path, VICEROY_LIMIT, &|| Ok(())) + .map(|measured| measured.sha256) + .map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Viceroy) + }) +} + +/// Parses `viceroy \n` and requires the pinned version. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Viceroy`] for any other output, including a +/// different, prefixed or suffixed version. +pub(in crate::recipe) fn parse_version_output( + stdout: &[u8], +) -> Result> { + let version = std::str::from_utf8(stdout) + .ok() + .and_then(|text| text.strip_suffix('\n')) + .and_then(|line| line.strip_prefix("viceroy ")) + .filter(|version| !version.contains(char::is_whitespace)) + .ok_or_else(|| selection_error("unrecognized Viceroy version output"))?; + if version != PINNED_VICEROY_VERSION { + return Err(selection_error(format!( + "Viceroy reports {version:?}, not the pinned {PINNED_VICEROY_VERSION}" + ))); + } + Ok(version.to_owned()) +} diff --git a/crates/trusted-server-release/src/recipe/sources.rs b/crates/trusted-server-release/src/recipe/sources.rs new file mode 100644 index 000000000..7347c7753 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/sources.rs @@ -0,0 +1,35 @@ +//! Cargo source-replacement arguments for the vendored dependency tree. + +use crate::materials::GitSourceConfig; + +/// Name of the directory source every locked source is replaced with. +pub(super) const VENDOR_SOURCE: &str = "ts-vendor"; + +/// Returns the `--config` pairs replacing every locked source with the vendor +/// directory. +/// +/// `vendor_directory` is the directory as the confined build sees it and must +/// already be plain text without quotes or backslashes. Each Git source is a +/// full `[source.""]` table (`git`, reference, `replace-with`), because +/// Cargo rejects a source table without a location. +pub(super) fn source_replacement_arguments( + vendor_directory: &str, + git_sources: &[GitSourceConfig], +) -> Vec { + let mut entries = vec![format!("source.crates-io.replace-with=\"{VENDOR_SOURCE}\"")]; + for source in git_sources { + let key = source.key(); + entries.push(format!("source.\"{key}\".git=\"{}\"", source.url())); + if let Some((kind, value)) = source.reference() { + entries.push(format!("source.\"{key}\".{kind}=\"{value}\"")); + } + entries.push(format!("source.\"{key}\".replace-with=\"{VENDOR_SOURCE}\"")); + } + entries.push(format!( + "source.{VENDOR_SOURCE}.directory=\"{vendor_directory}\"" + )); + entries + .into_iter() + .flat_map(|entry| ["--config".to_owned(), entry]) + .collect() +} diff --git a/crates/trusted-server-release/src/recipe/startup.rs b/crates/trusted-server-release/src/recipe/startup.rs new file mode 100644 index 000000000..dd385a638 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/startup.rs @@ -0,0 +1,702 @@ +//! Clean-container startup acceptance of the final Linux `ts` bytes. +//! +//! The exact final executable is copied once into a private read-only +//! directory and started as `ts --version` in each reviewed clean +//! distribution: the glibc 2.34 floor (UBI 9) and two newer mainstream +//! distributions. Each container runs by local image ID with no network, a +//! read-only root, an unprivileged numeric user, every capability dropped, +//! no privilege escalation and bounded processes, memory and CPU time. No +//! operator configuration exists in the container, and `--version` must +//! print exactly the owning build facts and exit 0, so the startup proves +//! the loader floor and that no configuration fallback runs before +//! argument parsing. The copy must still hash to the accepted digest after +//! every run. + +mod accepted; +mod lifecycle; +#[cfg(test)] +pub(crate) use accepted::NativeStartupAccepted; +pub(crate) mod native; + +use std::ffi::OsString; +use std::fs; +use std::io::Write as _; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::artifact::EXECUTABLE_LIMIT; +use super::compare::read_artifact; +use super::{RecipeCompleted, recipe_error}; +use crate::boundary::daemon::{ContainerId, DockerClient, host_arguments}; +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; +use crate::process::{ProcessLimits, ProcessOutcome, ProcessResult}; +use crate::source::CommitId; + +/// The only asset the Linux startup gate accepts. +pub(crate) const LINUX_CLI_ASSET: &str = "ts-x86_64-unknown-linux-gnu"; +/// Numeric unprivileged account inside every startup container. +const STARTUP_USER: &str = "65534:65534"; +/// Read-only mount point of the private copy inside the container. +const MOUNT_POINT: &str = "/opt/ts"; +/// File name of the private copy. +const EXECUTABLE_NAME: &str = "ts"; +/// Wall limit of attached execution; stopped-container creation is separately bounded. +const STARTUP_WALL: Duration = Duration::from_secs(120); +/// Captured stdout limit; the version line is short. +const STARTUP_STDOUT_MAX: u64 = 4096; +/// Captured stderr limit; the Docker client may warn about the kernel. +const STARTUP_STDERR_MAX: u64 = 64 * 1024; +/// Stderr bytes shown when a run is rejected. +const STDERR_TAIL_BYTES: usize = 4096; + +/// One reviewed clean distribution the Linux executable must start on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StartupDistribution { + /// Red Hat UBI 9, the glibc 2.34 floor. + Ubi9, + /// Ubuntu 22.04 LTS (glibc 2.35). + Ubuntu2204, + /// Debian 12 (glibc 2.36). + Debian12, +} + +impl StartupDistribution { + /// Every reviewed distribution, in run order. + pub(crate) const ALL: [Self; 3] = [Self::Ubi9, Self::Ubuntu2204, Self::Debian12]; + + /// Stable label used by fixtures and measurements. + pub(crate) fn label(self) -> &'static str { + match self { + Self::Ubi9 => "ubi9", + Self::Ubuntu2204 => "ubuntu-22.04", + Self::Debian12 => "debian-12", + } + } + + /// The tag-pinned image reference the ordinary CI fixture pulls. + /// + /// A tag is not an identity: every run selects the pulled image by its + /// local image ID, which the measurement records. + pub(crate) fn reference(self) -> &'static str { + match self { + Self::Ubi9 => "registry.access.redhat.com/ubi9/ubi-minimal:9.6", + Self::Ubuntu2204 => "docker.io/library/ubuntu:22.04", + Self::Debian12 => "docker.io/library/debian:12", + } + } + + /// Parses exactly one [`Self::label`]. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for any other text. + pub(crate) fn parse(label: &str) -> Result> { + Self::ALL + .into_iter() + .find(|distribution| distribution.label() == label) + .ok_or_else(|| recipe_error(format!("unknown startup distribution {label:?}"))) + } +} + +/// Reports whether `text` is a local image ID: `sha256:` and 64 lowercase +/// hex digits, never a tag or repository reference. +pub(crate) fn is_local_image_id(text: &str) -> bool { + text.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +/// A reviewed distribution selected by its local image ID. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StartupImage { + distribution: StartupDistribution, + id: String, +} + +impl StartupImage { + /// Selects `distribution` by the local image ID `id`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] unless `id` is `sha256:` followed by + /// 64 lowercase hex digits. + pub(crate) fn new( + distribution: StartupDistribution, + id: &str, + ) -> Result> { + if !is_local_image_id(id) { + return Err(recipe_error(format!( + "startup image for {} is not a local image ID", + distribution.label() + ))); + } + Ok(Self { + distribution, + id: id.to_owned(), + }) + } + + /// The selected distribution. + pub(crate) fn distribution(&self) -> StartupDistribution { + self.distribution + } + + /// The local image ID. + pub(crate) fn id(&self) -> &str { + &self.id + } +} + +/// A Docker client whose host inputs are pinned and checked before every command. +#[derive(Debug)] +pub(crate) struct StartupClient { + client: DockerClient, +} + +impl StartupClient { + /// Pins the Docker client and fresh private host inputs outside mounted roots. + /// + /// # Errors + /// + /// Returns an error for invalid, overlapping or unpinnable host inputs. + pub(crate) fn new( + docker: &Path, + state_parent: &Path, + mounted: &[&Path], + ) -> Result> { + Ok(Self { + client: DockerClient::new( + docker, + state_parent, + mounted, + ProcessLimits { + wall: Duration::from_secs(15), + stdout_max: 4096, + stderr_max: STARTUP_STDERR_MAX, + }, + )?, + }) + } + + /// Requires creation acknowledgement before any attached start request. + fn execute_created( + &self, + name: &str, + create_arguments: Vec, + limits: ProcessLimits, + before_start: impl FnOnce() -> Result<(), Report>, + accept: impl FnOnce(&ProcessResult) -> Result<(), Report>, + ) -> Result> { + lifecycle::run_created( + || { + self.client.create( + create_arguments, + ProcessLimits { + wall: Duration::from_secs(15), + stdout_max: 4096, + stderr_max: STARTUP_STDERR_MAX, + }, + ) + }, + |identity| { + before_start()?; + let result = self + .client + .command( + startup_attach_arguments(self.client.inputs().config(), identity), + limits, + )? + .run()?; + accept(&result)?; + Ok(result) + }, + |identity, step| self.cleanup(identity, name, step), + ) + } + + /// Stops and removes only the uniquely named container owned by this run. + fn cleanup( + &self, + identity: Option<&ContainerId>, + name: &str, + step: &str, + ) -> Result> { + let target = identity.map_or(name, ContainerId::as_str); + let filter = cleanup_filter(identity, name); + let arguments: &[&str] = match step { + "stop" => &["stop", "--time", "2", target], + "rm" => &["rm", "--force", target], + "ps" => &["ps", "--all", "--quiet", "--filter", &filter], + _ => return Err(recipe_error("unknown startup cleanup step")), + }; + let mut failures = Vec::new(); + let result = self.client.cleanup_host(arguments, &mut failures); + if !failures.is_empty() { + let mut report = failures.remove(0); + for failure in failures { + report = report.attach(format!("cleanup also failed: {failure:?}")); + } + return Err(report); + } + Ok(result.is_some_and(|result| { + result.outcome() == (ProcessOutcome::Exited { code: 0 }) + && (step != "ps" || result.stdout().is_empty()) + })) + } +} + +/// Selects the acknowledged immutable identity, or an unacknowledged owned name. +fn cleanup_filter(identity: Option<&ContainerId>, name: &str) -> String { + identity.map_or_else( + || format!("name=^/{name}$"), + |id| format!("id={}", id.as_str()), + ) +} + +/// Formats fixture version output using the current producer's workspace version. +/// +/// Owning [`accepted::NativeStartupAccepted`] gates derive the CLI version from their +/// sealed selected-package observations instead of this fixture formatter. +pub(crate) fn expected_version_output(commit: &CommitId, target: ReleaseTarget) -> String { + format!( + "ts package={} source={} target={} variant=default\n", + env!("CARGO_PKG_VERSION"), + commit.as_str(), + target.triple() + ) +} + +/// Returns the Docker client arguments of one startup run. +pub(crate) fn startup_arguments( + config: &Path, + image: &StartupImage, + copy_directory: &Path, + name: &str, +) -> Vec { + let mut arguments = host_arguments(config); + arguments.extend([ + "create".into(), + "--init".into(), + format!("--name={name}").into(), + "--pull=never".into(), + "--network=none".into(), + "--read-only".into(), + format!("--user={STARTUP_USER}").into(), + "--cap-drop=ALL".into(), + "--security-opt=no-new-privileges".into(), + "--pids-limit=64".into(), + "--memory=268435456".into(), + "--cpus=1".into(), + "--ulimit=cpu=30:30".into(), + "--workdir=/".into(), + ]); + let mut mount = OsString::from("--mount=type=bind,source="); + mount.push(copy_directory.as_os_str()); + mount.push(format!(",target={MOUNT_POINT},readonly")); + arguments.push(mount); + arguments.push(format!("--entrypoint={MOUNT_POINT}/{EXECUTABLE_NAME}").into()); + arguments.push(image.id.clone().into()); + arguments.push("--version".into()); + arguments +} + +/// Returns attached-start arguments for one acknowledged immutable identity. +pub(crate) fn startup_attach_arguments(config: &Path, identity: &ContainerId) -> Vec { + let mut arguments = host_arguments(config); + arguments.extend(["start".into(), "--attach".into(), identity.as_str().into()]); + arguments +} + +/// Accepts one startup run's outcome and stdout. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] unless the run exited 0 and printed +/// exactly `expected`. +pub(crate) fn check_startup_output( + distribution: StartupDistribution, + outcome: ProcessOutcome, + stdout: &[u8], + expected: &str, +) -> Result<(), Report> { + let label = distribution.label(); + if outcome != (ProcessOutcome::Exited { code: 0 }) { + return Err(recipe_error(format!("ts --version on {label} {outcome}"))); + } + if stdout != expected.as_bytes() { + return Err(recipe_error(format!( + "ts --version on {label} printed {:?}, expected {expected:?}", + String::from_utf8_lossy(stdout) + ))); + } + Ok(()) +} + +/// One accepted startup run. +#[derive(Clone, Debug)] +pub(crate) struct StartupObserved { + /// The image the run used. + pub(crate) image: StartupImage, + /// The exact accepted stdout. + pub(crate) stdout: String, + /// SHA-256 of the captured Docker client stderr. + pub(crate) stderr_sha256: String, + /// Wall time of the Docker client. + pub(crate) elapsed: Duration, +} + +/// A private read-only copy of the accepted final bytes. +#[derive(Debug)] +pub(crate) struct StartupCopy { + directory: PathBuf, + sha256: String, + size: u64, +} + +impl StartupCopy { + /// Copies the accepted Linux executable into the fresh `directory`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when `completed` is not the Linux + /// CLI asset or no longer rehashes, `directory` is not absolute UTF-8 + /// free of the `,` and `=` that would split the bind mount, `directory` + /// exists, or the copy cannot be written read-only. + pub(crate) fn create( + completed: &RecipeCompleted, + directory: &Path, + ) -> Result> { + if completed.asset().artifact().name != LINUX_CLI_ASSET { + return Err(recipe_error(format!( + "the startup gate accepts only {LINUX_CLI_ASSET}" + ))); + } + let mountable = directory.is_absolute() + && directory + .to_str() + .is_some_and(|text| !text.contains([',', '='])); + if !mountable { + return Err(recipe_error( + "startup copy directory cannot be bind-mounted verbatim", + )); + } + Self::write_private(completed, directory) + } + + /// Writes the accepted bytes as the only `0555` file of the fresh + /// `0555` `directory` and rehashes the copy. + fn write_private( + completed: &RecipeCompleted, + directory: &Path, + ) -> Result> { + let bytes = read_artifact(completed)?; + let io = |error: std::io::Error| recipe_error(format!("cannot copy for startup: {error}")); + fs::create_dir(directory).map_err(io)?; + let copy = Self { + directory: directory.to_owned(), + sha256: completed.artifact().sha256().to_owned(), + size: completed.artifact().size(), + }; + let path = directory.join(EXECUTABLE_NAME); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o555) + .open(&path) + .map_err(io)?; + file.write_all(&bytes).map_err(io)?; + file.sync_all().map_err(io)?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o555)).map_err(io)?; + fs::set_permissions(directory, fs::Permissions::from_mode(0o555)).map_err(io)?; + copy.verify()?; + Ok(copy) + } + + /// Requires the copy to still hash to the accepted digest. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when it cannot be read or changed. + pub(crate) fn verify(&self) -> Result<(), Report> { + let directory = fs::symlink_metadata(&self.directory) + .map_err(|error| recipe_error(format!("cannot inspect startup directory: {error}")))?; + if !directory.is_dir() + || fs::canonicalize(&self.directory).ok().as_deref() != Some(&self.directory) + { + return Err(recipe_error( + "startup directory is aliased or not a directory", + )); + } + let mut entries = fs::read_dir(&self.directory) + .map_err(|error| recipe_error(format!("cannot list startup directory: {error}")))?; + let entry = entries + .next() + .transpose() + .map_err(|error| recipe_error(format!("cannot inspect startup entry: {error}")))?; + if entry.is_none_or(|entry| entry.file_name() != EXECUTABLE_NAME) + || entries.next().is_some() + { + return Err(recipe_error( + "startup directory must hold only the executable", + )); + } + let digest = hash_bounded_with_check( + &self.directory.join(EXECUTABLE_NAME), + EXECUTABLE_LIMIT, + &|| Ok(()), + )?; + if digest.sha256 != self.sha256 || digest.size != self.size { + return Err(recipe_error("the startup copy changed")); + } + Ok(()) + } + + /// Starts the copy as `ts --version` in `image` and accepts the output. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] when the Docker client cannot run, + /// or [`ReleaseError::Recipe`] for a rejected outcome or output, or a + /// copy that changed during the run. + pub(crate) fn start( + &self, + client: &StartupClient, + image: &StartupImage, + expected: &str, + ) -> Result> { + let limits = ProcessLimits { + wall: STARTUP_WALL, + stdout_max: STARTUP_STDOUT_MAX, + stderr_max: STARTUP_STDERR_MAX, + }; + self.verify()?; + client.client.require_no_image_volumes(image.id())?; + let name = format!( + "ts-release-startup-{}", + hex::encode(crate::boundary::random_bytes::<12>()?) + ); + self.verify()?; + let result = client.execute_created( + &name, + startup_arguments( + client.client.inputs().config(), + image, + &self.directory, + &name, + ), + limits, + || self.verify(), + |result| { + check_startup_output( + image.distribution, + result.outcome(), + result.stdout(), + expected, + ) + .map_err(|error| { + let stderr = result.stderr(); + let tail = &stderr[stderr.len().saturating_sub(STDERR_TAIL_BYTES)..]; + error.attach(format!( + "docker stderr tail: {}", + String::from_utf8_lossy(tail) + )) + }) + }, + ); + let after = self.verify(); + let result = lifecycle::combine(result, after)?; + Ok(StartupObserved { + image: image.clone(), + stdout: expected.to_owned(), + stderr_sha256: hex::encode(Sha256::digest(result.stderr())), + elapsed: result.elapsed(), + }) + } + + /// Restores write permission and removes the copy. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the directory cannot be removed. + pub(crate) fn remove(self) -> Result<(), Report> { + self.remove_inner() + } + + fn remove_inner(&self) -> Result<(), Report> { + let io = |error: std::io::Error| recipe_error(format!("cannot remove the copy: {error}")); + let metadata = fs::symlink_metadata(&self.directory).map_err(io)?; + if !metadata.is_dir() + || fs::canonicalize(&self.directory).ok().as_deref() != Some(&self.directory) + { + return Err(recipe_error("cannot remove an aliased startup directory")); + } + fs::set_permissions(&self.directory, fs::Permissions::from_mode(0o700)).map_err(io)?; + fs::remove_dir_all(&self.directory).map_err(io) + } +} + +impl Drop for StartupCopy { + fn drop(&mut self) { + if self.directory.exists() { + let _ = self.remove_inner(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_client_pins_its_executable_and_empty_config() { + let scratch = tempfile::tempdir().expect("should create client scratch space"); + let base = fs::canonicalize(scratch.path()).expect("should resolve client scratch space"); + let program = base.join("docker"); + fs::write(&program, b"fictional Docker client bytes").expect("should write client bytes"); + let state = base.join("state"); + fs::create_dir(&state).expect("should create disjoint host state"); + let client = StartupClient::new(&program, &state, &[&base.join("mount")]) + .expect("should pin host inputs without launching a process"); + client + .client + .inputs() + .verify() + .expect("should verify fresh inputs"); + fs::write(&program, b"changed Docker client bytes").expect("should change selected client"); + assert!( + client.client.inputs().verify().is_err(), + "should reject a changed client before execution" + ); + fs::write(&program, b"fictional Docker client bytes").expect("should restore client bytes"); + let config = client.client.inputs().config(); + fs::set_permissions(config, fs::Permissions::from_mode(0o700)) + .expect("should reopen the configuration for a tamper case"); + fs::write(config.join("config.json"), b"fictional configuration") + .expect("should add unreviewed configuration"); + assert!( + client.client.inputs().verify().is_err(), + "should reject changed configuration before execution" + ); + } + + #[test] + fn acknowledged_container_is_attached_and_checked_by_immutable_id() { + let text = "a".repeat(64); + let identity = ContainerId::parse(text.as_bytes()).expect("should parse a full ID"); + let arguments = startup_attach_arguments(Path::new("/example/config"), &identity); + let expected: Vec = [ + "--config", + "/example/config", + "--host", + "unix:///var/run/docker.sock", + "start", + "--attach", + &text, + ] + .into_iter() + .map(OsString::from) + .collect(); + assert_eq!( + arguments, expected, + "should attach only to the acknowledged identity" + ); + assert_eq!( + cleanup_filter(Some(&identity), "ts-release-startup-example"), + format!("id={text}"), + "should confirm ID absence using the ID filter rather than a name filter" + ); + assert_eq!( + cleanup_filter(None, "ts-release-startup-example"), + "name=^/ts-release-startup-example$", + "should restrict unresolved creation cleanup to its owned name" + ); + } + + #[cfg(target_os = "linux")] + #[test] + #[ignore = "requires explicit ordinary CI CLI fixture and Linux Docker; starts only its own named container"] + fn startup_timeout_removes_only_its_owned_container() { + let root = PathBuf::from( + std::env::var_os("TS_RELEASE_CLI_FIXTURE_ROOT") + .expect("should explicitly supply the fixture root"), + ); + let raw = crate::documents::read_bounded( + &root.join("cli-fixture.json"), + crate::documents::Role::Metadata, + ) + .expect("should read bounded fixture description"); + let document = crate::documents::parse_json(&raw.bytes, crate::documents::Role::Metadata) + .expect("should parse fixture description"); + let id = document["startup_images"][1]["image_id"] + .as_str() + .expect("should name Ubuntu image"); + let image = StartupImage::new(StartupDistribution::Ubuntu2204, id) + .expect("should select local image"); + let scratch = tempfile::tempdir().expect("should create private host state"); + let state = fs::canonicalize(scratch.path()).expect("should resolve host state"); + let client = StartupClient::new(Path::new("/usr/bin/docker"), &state, &[]) + .expect("should pin Docker host inputs"); + let name = format!( + "ts-release-startup-{}", + hex::encode( + crate::boundary::random_bytes::<12>().expect("should choose an owned name") + ) + ); + let mut arguments = host_arguments(client.client.inputs().config()); + arguments.extend( + [ + "create", + "--init", + "--pull=never", + "--network=none", + "--read-only", + "--user=65534:65534", + "--cap-drop=ALL", + "--security-opt=no-new-privileges", + "--pids-limit=16", + "--memory=67108864", + "--cpus=1", + ] + .map(OsString::from), + ); + arguments.extend([ + format!("--name={name}").into(), + "--entrypoint=/bin/sleep".into(), + image.id().into(), + "60".into(), + ]); + let result = client + .execute_created( + &name, + arguments, + ProcessLimits { + wall: Duration::from_millis(500), + stdout_max: 4096, + stderr_max: STARTUP_STDERR_MAX, + }, + || Ok(()), + |_| Ok(()), + ) + .expect("should clean up after the bounded client timeout"); + assert_eq!( + result.outcome(), + ProcessOutcome::TimedOut, + "should exercise a real client timeout" + ); + assert!( + client + .cleanup(None, &name, "ps") + .expect("should independently confirm absence"), + "should leave no owned container" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe/startup/accepted.rs b/crates/trusted-server-release/src/recipe/startup/accepted.rs new file mode 100644 index 000000000..8ee226e89 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/startup/accepted.rs @@ -0,0 +1,347 @@ +//! Owning native startup acceptance, without qualification or signing authority. + +use std::collections::BTreeSet; +use std::path::Path; + +use error_stack::Report; +use serde_json::{Value, json}; + +#[cfg(target_os = "macos")] +use super::native::NativeStartupLayout; +use super::native::NativeStartupObserved; +use super::{ + StartupClient, StartupCopy, StartupDistribution, StartupImage, StartupObserved, lifecycle, +}; +use crate::composition::origin::Original; +use crate::documents::{Role as DocumentRole, canonical_json}; +use crate::error::ReleaseError; +#[cfg(target_os = "macos")] +use crate::inspect::{MachoCpu, MachoPolicy, inspect_macho}; +use crate::policy::ReleaseTarget; +use crate::recipe::{AssetId, RecipeCompleted, Role, recipe_error}; +use crate::source::CommitId; + +#[cfg(test)] +mod tests; + +#[derive(Debug, PartialEq, Eq)] +struct Owner { + source_commit: CommitId, + role: Role, + asset: AssetId, + artifact_sha256: String, + artifact_size: u64, + package_version: String, +} + +struct CliVersionInputs<'a> { + name: &'a str, + version: &'a str, + origin_name: &'a str, + origin_version: &'a str, + original: &'a Original, + commit: &'a CommitId, +} + +fn checked_cli_version(inputs: &CliVersionInputs<'_>) -> Result> { + if inputs.name != "trusted-server-cli" + || inputs.origin_name != inputs.name + || inputs.origin_version != inputs.version + || inputs.version.is_empty() + || inputs.version.len() > 128 + || inputs + .version + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(recipe_error( + "startup CLI version is not the verified selected package version", + )); + } + match inputs.original { + Original::Workspace { + source_commit, + path, + } if source_commit == inputs.commit.as_str() + && path == Path::new("crates/trusted-server-cli/Cargo.toml") => + { + Ok(inputs.version.to_owned()) + } + _ => Err(recipe_error( + "startup selected CLI has no matching approved workspace origin", + )), + } +} + +impl Owner { + fn from_completed(completed: &RecipeCompleted) -> Result> { + completed.recipe_definition()?; + if completed.asset().artifact().package != "trusted-server-cli" + || completed.asset().artifact().target == ReleaseTarget::Wasm32Wasip1 + { + return Err(recipe_error( + "native startup requires an owning native CLI build", + )); + } + let view = completed.composition_view()?; + let origins = view.package_origins(); + if origins.source_commit() != completed.commit().as_str() + || origins.materials_sha256() != completed.materials_digest() + { + return Err(recipe_error( + "startup CLI origins differ from the completed owner", + )); + } + let id = view.graph().selected_package_id(); + let mut packages = view + .observations() + .packages() + .iter() + .filter(|package| package.id() == id); + let package = packages + .next() + .ok_or_else(|| recipe_error("startup has no selected CLI package"))?; + if packages.next().is_some() { + return Err(recipe_error("startup selected CLI package is ambiguous")); + } + let origin = origins + .package(id) + .ok_or_else(|| recipe_error("startup selected CLI has no verified origin"))?; + let package_version = checked_cli_version(&CliVersionInputs { + name: package.name(), + version: package.version(), + origin_name: origin.name(), + origin_version: origin.version(), + original: origin.origin(), + commit: completed.commit(), + })?; + Ok(Self { + source_commit: completed.commit().clone(), + role: completed.role(), + asset: completed.asset(), + artifact_sha256: completed.artifact().sha256().to_owned(), + artifact_size: completed.artifact().size(), + package_version, + }) + } + + fn expected_stdout(&self) -> String { + format!( + "ts package={} source={} target={} variant=default\n", + self.package_version, + self.source_commit.as_str(), + self.asset.artifact().target.triple() + ) + } + + fn check_same(&self, other: &Self) -> Result<(), Report> { + if self != other { + return Err(recipe_error( + "native startup acceptance belongs to another source, role, asset, artifact or CLI version", + )); + } + Ok(()) + } + + fn check_copy(&self, copy: &StartupCopy) -> Result<(), Report> { + if copy.sha256 != self.artifact_sha256 || copy.size != self.artifact_size { + return Err(recipe_error( + "startup copy differs from the owning final bytes", + )); + } + copy.verify() + } +} + +fn check_linux_images(images: &[StartupImage]) -> Result<(), Report> { + if images.len() != StartupDistribution::ALL.len() { + return Err(recipe_error( + "Linux startup requires all three reviewed distributions", + )); + } + let mut identities = BTreeSet::new(); + for (image, distribution) in images.iter().zip(StartupDistribution::ALL) { + if image.distribution() != distribution || !identities.insert(image.id()) { + return Err(recipe_error( + "Linux startup selection repeats or substitutes a reviewed distribution", + )); + } + } + Ok(()) +} + +#[derive(Debug)] +enum Observed { + Linux(Vec), + Macos { + startup: NativeStartupObserved, + minimum_os: String, + sdk: String, + }, +} + +/// Successful actual native startup retained independently of its working copy. +/// +/// Only actual owning startup methods construct this type. Public low-level +/// observations and serialized diagnostics cannot reconstruct acceptance. +#[derive(Debug)] +pub(crate) struct NativeStartupAccepted { + owner: Owner, + observed: Observed, +} + +impl NativeStartupAccepted { + /// Starts the owning Linux CLI bytes on all three reviewed clean selections. + /// + /// The supplied copy is consumed and removed on success or failure. Expected + /// output comes from the sealed selected CLI package and approved source. + /// + /// # Errors + /// + /// Rejects mismatched owner/copy, incomplete or duplicate image selections, + /// failed startup, changed bytes or failed cleanup. Local image IDs are not + /// authenticated distribution provenance. + pub(crate) fn accept_linux( + completed: &RecipeCompleted, + copy: StartupCopy, + client: &StartupClient, + images: &[StartupImage], + ) -> Result> { + let run = (|| { + let owner = Owner::from_completed(completed)?; + if owner.asset.artifact().target != ReleaseTarget::X86_64UnknownLinuxGnu { + return Err(recipe_error("Linux startup requires the Linux CLI target")); + } + owner.check_copy(©)?; + check_linux_images(images)?; + let expected = owner.expected_stdout(); + let runs = images + .iter() + .map(|image| copy.start(client, image, &expected)) + .collect::, _>>()?; + owner.check_copy(©)?; + completed.rehash_for_handoff()?; + Ok((owner, Observed::Linux(runs))) + })(); + let (owner, observed) = lifecycle::combine(run, copy.remove())?; + completed.rehash_for_handoff()?; + Ok(Self { owner, observed }) + } + + /// Starts the owning Darwin CLI on its actual native macOS host. + /// + /// The same measured host that passes the execution gate is retained. The + /// accepted Mach-O CPU, minimum OS and SDK come from the owning final bytes. + /// + /// # Errors + /// + /// Rejects another owner/target/copy, nonnative host, incompatible minimum OS, + /// failed startup, changed bytes or failed cleanup. + #[cfg(target_os = "macos")] + pub(crate) fn accept_macos( + completed: &RecipeCompleted, + copy: StartupCopy, + layout: &NativeStartupLayout<'_>, + ) -> Result> { + let run = (|| { + let owner = Owner::from_completed(completed)?; + let target = owner.asset.artifact().target; + let cpu = match target { + ReleaseTarget::Aarch64AppleDarwin => MachoCpu::Arm64, + ReleaseTarget::X86_64AppleDarwin => MachoCpu::X86_64, + _ => { + return Err(recipe_error( + "native macOS startup requires a Darwin CLI target", + )); + } + }; + owner.check_copy(©)?; + let bytes = crate::recipe::compare::read_artifact(completed)?; + let facts = inspect_macho(&bytes, &MachoPolicy::macos(cpu))?; + let view = completed.composition_view()?; + let requirements = view.platform().external_requirements(); + if requirements.target() != target + || requirements.artifact_sha256() != owner.artifact_sha256 + || requirements.macho_sdk() != Some(facts.sdk().to_string().as_str()) + { + return Err(recipe_error( + "native startup Mach-O facts differ from the sealed platform requirements", + )); + } + let startup = + copy.start_native(layout, target, &owner.expected_stdout(), facts.minos())?; + owner.check_copy(©)?; + completed.rehash_for_handoff()?; + Ok(( + owner, + Observed::Macos { + startup, + minimum_os: facts.minos().to_string(), + sdk: facts.sdk().to_string(), + }, + )) + })(); + let (owner, observed) = lifecycle::combine(run, copy.remove())?; + completed.rehash_for_handoff()?; + Ok(Self { owner, observed }) + } + + /// Borrows actual Linux run observations when this acceptance is Linux. + pub(crate) fn linux_runs(&self) -> Option<&[StartupObserved]> { + match &self.observed { + Observed::Linux(runs) => Some(runs), + _ => None, + } + } + /// Borrows actual native macOS stdout when this acceptance is Darwin. + pub(crate) fn macos_stdout(&self) -> Option<&str> { + match &self.observed { + Observed::Macos { startup, .. } => Some(&startup.stdout), + _ => None, + } + } + + /// Exports successful startup facts for this exact completed build. + /// + /// # Errors + /// + /// Rejects changed retained outputs or another source, role, asset, artifact + /// or selected CLI version. JSON provides diagnostics without authority. + pub(crate) fn diagnostic_for( + &self, + completed: &RecipeCompleted, + ) -> Result> { + let actual = Owner::from_completed(completed)?; + self.owner.check_same(&actual)?; + let record = self.diagnostic_record()?; + completed.rehash_for_handoff()?; + Ok(record) + } + + fn diagnostic_record(&self) -> Result> { + let observation = match &self.observed { + Observed::Linux(runs) => { + json!({"kind":"linux-clean-containers", "runs":runs.iter().map(|run|Ok(json!({"distribution":run.image.distribution().label(),"reference":run.image.distribution().reference(),"image_id":run.image.id(),"exit_code":0,"stdout":run.stdout,"stderr_sha256":run.stderr_sha256,"elapsed_milliseconds":milliseconds(run.elapsed)?}))).collect::,Report>>()?,"environment_scope":"selected_image_defaults_no_host_environment_injection","image_authentication":"unqualified-local-image-selection"}) + } + Observed::Macos { + startup, + minimum_os, + sdk, + } => { + json!({"kind":"native-macos", "host":startup.host,"minimum_os":minimum_os,"sdk":sdk,"exit_code":0,"stdout":startup.stdout,"stderr_sha256":startup.stderr_sha256,"elapsed_milliseconds":milliseconds(startup.elapsed)?,"environment":{"PATH":"/usr/bin:/bin","LC_ALL":"C","HOME":"private","TMPDIR":"private"}}) + } + }; + let record = json!({"domain":"trusted-server.release.native-startup-diagnostic","schema_version":1,"release_qualification":false,"source_commit":self.owner.source_commit.as_str(),"role":self.owner.role.spec_label(),"asset":self.owner.asset.artifact().name,"target":self.owner.asset.artifact().target.triple(),"package_version":self.owner.package_version,"artifact":{"sha256":self.owner.artifact_sha256,"size":self.owner.artifact_size},"startup":observation}); + canonical_json(&record, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + Ok(record) + } +} + +fn milliseconds(duration: std::time::Duration) -> Result> { + u64::try_from(duration.as_millis()).map_err(|error| { + recipe_error(format!( + "startup duration exceeds diagnostic bound: {error}" + )) + }) +} diff --git a/crates/trusted-server-release/src/recipe/startup/accepted/tests.rs b/crates/trusted-server-release/src/recipe/startup/accepted/tests.rs new file mode 100644 index 000000000..5b778bb83 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/startup/accepted/tests.rs @@ -0,0 +1,209 @@ +use super::*; + +fn owner() -> Owner { + Owner { + source_commit: crate::source::CommitId::parse(&"a".repeat(40)) + .expect("should parse source"), + role: crate::recipe::Role::Canonical, + asset: crate::recipe::AssetId::parse("ts-x86_64-unknown-linux-gnu") + .expect("should resolve CLI"), + artifact_sha256: "b".repeat(64), + artifact_size: 128, + package_version: "9.8.7".to_owned(), + } +} + +#[test] +fn startup_expected_output_uses_the_sealed_cli_version() { + let owner = owner(); + assert_eq!( + owner.expected_stdout(), + format!( + "ts package=9.8.7 source={} target=x86_64-unknown-linux-gnu variant=default\n", + "a".repeat(40) + ), + "should derive output from owning package facts rather than producer version" + ); +} + +#[test] +fn linux_startup_requires_three_exact_distinct_selections() { + let images: Vec<_> = crate::recipe::startup::StartupDistribution::ALL + .iter() + .enumerate() + .map(|(index, distribution)| { + crate::recipe::startup::StartupImage::new( + *distribution, + &format!("sha256:{}", (index + 1).to_string().repeat(64)), + ) + .expect("should create selected image") + }) + .collect(); + check_linux_images(&images).expect("should accept the complete reviewed startup set"); + assert!( + check_linux_images(&images[..2]).is_err(), + "should reject a missing distribution" + ); + let duplicates = vec![images[0].clone(), images[0].clone(), images[2].clone()]; + assert!( + check_linux_images(&duplicates).is_err(), + "should reject repeated distribution" + ); + let wrong_id = vec![ + images[0].clone(), + crate::recipe::startup::StartupImage::new( + crate::recipe::startup::StartupDistribution::Ubuntu2204, + images[0].id(), + ) + .expect("should select local image"), + images[2].clone(), + ]; + assert!( + check_linux_images(&wrong_id).is_err(), + "should not reuse one image as multiple distributions" + ); +} + +#[test] +fn native_startup_owner_rejects_another_source_role_asset_artifact_or_cli_version() { + let original = owner(); + original + .check_same(&owner()) + .expect("should accept exact owner binding"); + for field in ["source", "role", "asset", "digest", "size", "version"] { + let mut other = owner(); + match field { + "source" => { + other.source_commit = + crate::source::CommitId::parse(&"c".repeat(40)).expect("should parse source") + } + "role" => other.role = crate::recipe::Role::Reproduction, + "asset" => { + other.asset = crate::recipe::AssetId::parse("ts-aarch64-apple-darwin") + .expect("should resolve other asset") + } + "digest" => other.artifact_sha256 = "c".repeat(64), + "size" => other.artifact_size += 1, + "version" => other.package_version = "1.2.3".to_owned(), + _ => unreachable!("should select fixture field"), + } + assert!( + original.check_same(&other).is_err(), + "should reject different {field} owner" + ); + } +} + +#[test] +fn cli_version_requires_the_exact_selected_approved_workspace_original() { + let owner = owner(); + let original = crate::composition::origin::Original::Workspace { + source_commit: owner.source_commit.as_str().to_owned(), + path: "crates/trusted-server-cli/Cargo.toml".into(), + }; + let valid = CliVersionInputs { + name: "trusted-server-cli", + version: "9.8.7", + origin_name: "trusted-server-cli", + origin_version: "9.8.7", + original: &original, + commit: &owner.source_commit, + }; + assert_eq!( + checked_cli_version(&valid).expect("should use verified selected CLI version"), + "9.8.7", + "should retain source-owned CLI package version" + ); + for original in [ + Original::Workspace { + source_commit: "f".repeat(40), + path: "crates/trusted-server-cli/Cargo.toml".into(), + }, + Original::Workspace { + source_commit: owner.source_commit.as_str().to_owned(), + path: "crates/fictional-cli/Cargo.toml".into(), + }, + ] { + assert!( + checked_cli_version(&CliVersionInputs { + original: &original, + ..valid + }) + .is_err(), + "should reject another approved workspace source or package path" + ); + } + assert!( + checked_cli_version(&CliVersionInputs { + origin_version: "1.0.0", + ..valid + }) + .is_err(), + "should reject metadata version differing from verified source" + ); + assert!( + checked_cli_version(&CliVersionInputs { + version: "9.8.7\nforged", + origin_version: "9.8.7\nforged", + ..valid + }) + .is_err(), + "should reject version output injection" + ); +} + +#[test] +fn retained_linux_diagnostic_owns_all_run_facts_and_allows_explicit_optional_b() { + let mut owner = owner(); + owner.role = crate::recipe::Role::Reproduction; + let runs = StartupDistribution::ALL + .iter() + .enumerate() + .map(|(index, distribution)| StartupObserved { + image: StartupImage::new( + *distribution, + &format!("sha256:{}", (index + 1).to_string().repeat(64)), + ) + .expect("should select image"), + stdout: owner.expected_stdout(), + stderr_sha256: "d".repeat(64), + elapsed: std::time::Duration::from_millis(12), + }) + .collect(); + let accepted = NativeStartupAccepted { + owner, + observed: Observed::Linux(runs), + }; + let record = accepted + .diagnostic_record() + .expect("should retain owned run facts without startup working roots"); + assert_eq!( + record["role"], "B", + "should preserve an actually observed optional reproduction startup" + ); + assert_eq!( + record["package_version"], "9.8.7", + "should retain actual selected CLI version" + ); + assert_eq!( + record["startup"]["runs"] + .as_array() + .expect("should retain startup runs") + .len(), + 3, + "should retain all reviewed clean-image observations" + ); + assert_eq!( + record["startup"]["runs"][0]["elapsed_milliseconds"], 12, + "should retain integer bounded duration" + ); + assert_eq!( + record["startup"]["runs"][0]["image_id"], + format!("sha256:{}", "1".repeat(64)), + "should bind actual selected local image identity" + ); + assert_eq!( + record["release_qualification"], false, + "should not infer qualification from retained observations" + ); +} diff --git a/crates/trusted-server-release/src/recipe/startup/lifecycle.rs b/crates/trusted-server-release/src/recipe/startup/lifecycle.rs new file mode 100644 index 000000000..bddea561d --- /dev/null +++ b/crates/trusted-server-release/src/recipe/startup/lifecycle.rs @@ -0,0 +1,193 @@ +//! Unconditional bounded cleanup sequencing for an owned startup container. + +use error_stack::Report; + +use crate::error::ReleaseError; + +/// Runs an owned container and attempts stop, removal and absence confirmation. +pub(super) fn run_owned( + run: impl FnOnce() -> Result>, + mut cleanup: impl FnMut(&str) -> Result>, +) -> Result> { + let mut outcome = run(); + for step in ["stop", "rm", "ps"] { + let checked = cleanup(step).and_then(|confirmed| { + if step == "ps" && !confirmed { + Err(crate::recipe::recipe_error( + "startup container was not confirmed removed", + )) + } else { + Ok(()) + } + }); + outcome = combine(outcome, checked); + } + outcome +} + +/// Preserves the original run failure and any later integrity or cleanup failure. +pub(super) fn combine( + run: Result>, + later: Result<(), Report>, +) -> Result> { + match (run, later) { + (Ok(value), Ok(())) => Ok(value), + (Err(error), Ok(())) | (Ok(_), Err(error)) => Err(error), + (Err(error), Err(later)) => Err(error.attach(format!("startup also failed: {later:?}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::recipe::recipe_error; + + #[test] + fn every_run_outcome_attempts_all_cleanup_steps() { + for run_failure in [false, true] { + for cleanup_failure in [None, Some("stop"), Some("rm"), Some("ps")] { + let mut observed = Vec::new(); + let outcome = run_owned( + || { + if run_failure { + Err(recipe_error("launch or supervision failed")) + } else { + Ok(()) + } + }, + |step| { + observed.push(step.to_owned()); + if cleanup_failure == Some(step) { + Err(recipe_error(format!("{step} failed"))) + } else { + Ok(true) + } + }, + ); + assert_eq!( + observed, + ["stop", "rm", "ps"], + "should always attempt all cleanup" + ); + assert_eq!( + outcome.is_err(), + run_failure || cleanup_failure.is_some(), + "should preserve every failure" + ); + if run_failure { + assert!( + format!("{:?}", outcome.expect_err("should reject failed run")) + .contains("launch or supervision failed"), + "should retain the original failure" + ); + } + } + } + } + + #[test] + fn absence_confirmation_is_mandatory() { + let outcome = run_owned(|| Ok(()), |step| Ok(step != "ps")); + assert!( + outcome.is_err(), + "should reject a surviving or unconfirmed container" + ); + } +} + +/// Starts guest code only after acknowledged creation, then cleans that identity. +pub(super) fn run_created( + create: impl FnOnce() -> Result>, + run: impl FnOnce(&C) -> Result>, + mut cleanup: impl FnMut(Option<&C>, &str) -> Result>, +) -> Result> { + match create() { + Ok(created) => run_owned(|| run(&created), |step| cleanup(Some(&created), step)), + Err(error) => run_owned( + || { + Err(error.attach("container creation remains unresolved despite fallback cleanup; no guest start was issued")) + }, + |step| cleanup(None, step), + ), + } +} + +#[cfg(test)] +mod creation_tests { + use std::cell::Cell; + + use super::*; + use crate::recipe::recipe_error; + + #[test] + fn failed_creation_never_starts_and_cannot_be_cleared_by_absence() { + let started = Cell::new(false); + let mut steps = Vec::new(); + let result = run_created( + || Err::(recipe_error("create timed out")), + |_| { + started.set(true); + Ok(()) + }, + |identity, step| { + assert!( + identity.is_none(), + "should use name cleanup without acknowledged identity" + ); + steps.push(step.to_owned()); + Ok(true) + }, + ); + assert!(!started.get(), "should never start after failed creation"); + assert_eq!( + steps, + ["stop", "rm", "ps"], + "should attempt every bounded fallback cleanup" + ); + let error = format!( + "{:?}", + result.expect_err("should retain unresolved creation") + ); + assert!( + error.contains("create timed out") && error.contains("unresolved"), + "should preserve original and unresolved creation errors: {error}" + ); + } + + #[test] + fn execution_uses_acknowledged_identity_for_all_cleanup() { + for fail_start in [false, true] { + let mut steps = Vec::new(); + let result = run_created( + || Ok(7_u8), + |identity| { + assert_eq!(*identity, 7, "should start only the acknowledged identity"); + if fail_start { + Err(recipe_error("attach timed out")) + } else { + Ok(()) + } + }, + |identity, step| { + assert_eq!( + identity, + Some(&7), + "should clean only the acknowledged identity" + ); + steps.push(step.to_owned()); + Ok(true) + }, + ); + assert_eq!( + steps, + ["stop", "rm", "ps"], + "should always clean after execution" + ); + assert_eq!( + result.is_err(), + fail_start, + "should preserve execution outcome" + ); + } + } +} diff --git a/crates/trusted-server-release/src/recipe/startup/native.rs b/crates/trusted-server-release/src/recipe/startup/native.rs new file mode 100644 index 000000000..feb392b9b --- /dev/null +++ b/crates/trusted-server-release/src/recipe/startup/native.rs @@ -0,0 +1,611 @@ +//! Actual macOS startup acceptance of the final Darwin `ts` bytes. +//! +//! The policy is pure and compiled everywhere; only measuring the host and +//! executing the copy are macOS-only. Before anything runs, the host must be +//! the reviewed cohort: macOS 15 on the asset's own CPU. An arm64 host must +//! report `sysctl.proc_translated == 0`. An Intel host must not be a +//! translated process on Apple silicon: `sysctl.proc_translated` absent or +//! `0` and `hw.optional.arm64` absent or `0`, so Rosetta can never stand in +//! for real Intel hardware. +//! +//! The exact final bytes are copied once into a fresh `0555` directory +//! outside every build root and started as `ts --version` with exactly +//! `PATH=/usr/bin:/bin`, a private `HOME` and `TMPDIR` and `LC_ALL=C`. No +//! operator configuration exists, so the output must be exactly the owning +//! build facts. The copy must still hash to the accepted digest afterwards. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +#[cfg(target_os = "macos")] +use sha2::{Digest as _, Sha256}; + +use super::super::fresh::{disjoint, normal_absolute}; +use super::super::{RecipeCompleted, recipe_error}; +use super::StartupCopy; +use crate::error::ReleaseError; +use crate::inspect::MacosVersion; +use crate::policy::ReleaseTarget; +#[cfg(target_os = "macos")] +use crate::process::{BoundedCommand, ProcessLimits, ProcessOutcome}; + +/// The only macOS major version the startup cohort accepts. +pub(crate) const REQUIRED_MACOS_MAJOR: u32 = 15; +/// The fixed search path of the startup child. +const STARTUP_PATH: &str = "/usr/bin:/bin"; +/// The executable name inside the copy directory. +const EXECUTABLE_NAME: &str = "ts"; + +/// Host facts measured before the startup runs. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub(crate) struct HostIdentity { + /// `kern.osproductversion`, such as `15.5`. + pub(crate) product_version: String, + /// Architecture of the running producer: `aarch64` or `x86_64`. + pub(crate) architecture: String, + /// `sysctl.proc_translated`, or `None` when the name is absent. + pub(crate) proc_translated: Option, + /// `hw.optional.arm64`, or `None` when the name is absent. + pub(crate) hw_optional_arm64: Option, +} + +/// Facts retained from the same actual native host and successful startup run. +#[derive(Debug)] +pub(crate) struct NativeStartupObserved { + pub(super) host: HostIdentity, + pub(super) stdout: String, + pub(super) stderr_sha256: String, + pub(super) elapsed: std::time::Duration, +} + +/// Returns the major version of dotted decimal `text`. +fn major_version(text: &str) -> Option { + let mut parts = text.split('.'); + let major = parts.next()?; + let canonical = parts.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) + && !major.is_empty() + && major.bytes().all(|byte| byte.is_ascii_digit()) + && !major.starts_with('0'); + canonical.then(|| major.parse().ok()).flatten() +} + +/// Requires the host to be the reviewed macOS 15 cohort for `target`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for a non-Darwin target, another or +/// malformed macOS version, another architecture, a translated arm64 +/// process, or an Intel target on a translated process or Apple silicon. +pub(crate) fn check_host_identity( + target: ReleaseTarget, + host: &HostIdentity, +) -> Result<(), Report> { + if major_version(&host.product_version) != Some(REQUIRED_MACOS_MAJOR) { + return Err(recipe_error(format!( + "startup host runs macOS {:?}, expected macOS {REQUIRED_MACOS_MAJOR}", + host.product_version + ))); + } + let native = match target { + ReleaseTarget::Aarch64AppleDarwin => { + host.architecture == "aarch64" && host.proc_translated == Some(0) + } + ReleaseTarget::X86_64AppleDarwin => { + host.architecture == "x86_64" + && matches!(host.proc_translated, None | Some(0)) + && matches!(host.hw_optional_arm64, None | Some(0)) + } + ReleaseTarget::Wasm32Wasip1 | ReleaseTarget::X86_64UnknownLinuxGnu => { + return Err(recipe_error(format!( + "native macOS startup does not accept {}", + target.triple() + ))); + } + }; + if !native { + return Err(recipe_error(format!( + "startup host {host:?} is not a native {} machine", + target.triple() + ))); + } + Ok(()) +} + +/// Requires the actual host to satisfy the accepted artifact's deployment floor. +fn check_host_minimum( + target: ReleaseTarget, + host: &HostIdentity, + minimum: MacosVersion, +) -> Result<(), Report> { + check_host_identity(target, host)?; + let current = MacosVersion::parse(&host.product_version).ok_or_else(|| { + recipe_error("native startup host version cannot be compared to the artifact minimum") + })?; + if current < minimum { + return Err(recipe_error( + "native startup host is below the accepted deployment minimum", + )); + } + Ok(()) +} + +/// Where the startup copy and its private state live. +pub(crate) struct NativeStartupLayout<'a> { + /// Fresh directory receiving the `0555` copy; must not exist yet. + pub(crate) copy_directory: &'a Path, + /// Private `HOME` of the child. + pub(crate) home: &'a Path, + /// Private `TMPDIR` of the child. + pub(crate) tmpdir: &'a Path, + /// Every root the build used; none may contain or sit inside the above. + pub(crate) build_roots: &'a [&'a Path], +} + +/// The exact `ts --version` invocation of the native startup. +/// +/// Its fields are private: only [`native_startup_command`] constructs it, +/// after checking the layout. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct NativeStartupCommand { + /// The copied executable. + program: PathBuf, + /// Exactly `--version`. + arguments: Vec, + /// Exactly `PATH`, `HOME`, `TMPDIR` and `LC_ALL`. + environment: BTreeMap, + /// The copy directory. + working_directory: PathBuf, +} + +/// Derives the startup invocation after checking the layout. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when a path is not absolute normal UTF-8 +/// or the copy directory, `HOME`, `TMPDIR` and build roots are not pairwise +/// disjoint. +pub(crate) fn native_startup_command( + layout: &NativeStartupLayout<'_>, +) -> Result> { + let mut labelled = vec![ + ("startup copy directory", layout.copy_directory), + ("startup HOME", layout.home), + ("startup TMPDIR", layout.tmpdir), + ]; + labelled.extend(layout.build_roots.iter().map(|root| ("build root", *root))); + for (label, path) in &labelled { + normal_absolute(label, path)?; + } + disjoint(&labelled)?; + let text = |path: &Path| { + path.to_str() + .map(str::to_owned) + .ok_or_else(|| recipe_error("startup path is not UTF-8")) + }; + let environment = BTreeMap::from([ + ("PATH".to_owned(), STARTUP_PATH.to_owned()), + ("HOME".to_owned(), text(layout.home)?), + ("TMPDIR".to_owned(), text(layout.tmpdir)?), + ("LC_ALL".to_owned(), "C".to_owned()), + ]); + Ok(NativeStartupCommand { + program: layout.copy_directory.join(EXECUTABLE_NAME), + arguments: vec!["--version".to_owned()], + environment, + working_directory: layout.copy_directory.to_owned(), + }) +} + +impl StartupCopy { + /// Copies an accepted Darwin executable into the fresh `directory`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when `completed` is not a Darwin + /// asset or no longer rehashes, `directory` is not absolute normal UTF-8 + /// or exists, or the copy cannot be written read-only. + pub(crate) fn create_native( + completed: &RecipeCompleted, + directory: &Path, + ) -> Result> { + if !completed.asset().artifact().target.is_darwin() { + return Err(recipe_error( + "the native startup gate accepts only Darwin assets", + )); + } + normal_absolute("startup copy directory", directory)?; + Self::write_private(completed, directory) + } + + /// Measures the host, derives the command from `layout` and starts the + /// copy natively, accepting exactly `expected`. + /// + /// Nothing runs unless the measured host passes + /// [`check_host_identity`] for `target`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] when the child cannot run, or + /// [`ReleaseError::Recipe`] when the host cannot be measured or is not + /// the reviewed cohort for `target`, the layout is rejected, the command + /// is not this copy's, the outcome or output is rejected, or the copy + /// changed. + #[cfg(target_os = "macos")] + pub(crate) fn start_native( + &self, + layout: &NativeStartupLayout<'_>, + target: ReleaseTarget, + expected: &str, + minimum: MacosVersion, + ) -> Result> { + let host = measure_host_identity()?; + check_host_minimum(target, &host, minimum)?; + let command = native_startup_command(layout)?; + if command.program != self.directory.join(EXECUTABLE_NAME) { + return Err(recipe_error("startup command does not run this copy")); + } + self.verify()?; + let mut bounded = BoundedCommand::new( + &command.program, + &command.working_directory, + ProcessLimits { + wall: super::STARTUP_WALL, + stdout_max: super::STARTUP_STDOUT_MAX, + stderr_max: super::STARTUP_STDERR_MAX, + }, + )? + .arguments(command.arguments.iter().map(std::ffi::OsString::from)); + for (name, value) in &command.environment { + bounded = bounded.environment(name, value)?; + } + let result = bounded.run(); + self.verify()?; + let result = result?; + if result.outcome() != (ProcessOutcome::Exited { code: 0 }) { + return Err(recipe_error(format!( + "native ts --version {}", + result.outcome() + ))); + } + if result.stdout() != expected.as_bytes() { + return Err(recipe_error(format!( + "native ts --version printed {:?}, expected {expected:?}", + String::from_utf8_lossy(result.stdout()) + ))); + } + Ok(NativeStartupObserved { + host, + stdout: expected.to_owned(), + stderr_sha256: hex::encode(Sha256::digest(result.stderr())), + elapsed: result.elapsed(), + }) + } +} + +/// Measures the running host with `sysctlbyname`; nothing is executed. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the product version cannot be read. +#[cfg(target_os = "macos")] +pub(crate) fn measure_host_identity() -> Result> { + Ok(HostIdentity { + product_version: sysctl_text("kern.osproductversion")?, + architecture: std::env::consts::ARCH.to_owned(), + proc_translated: sysctl_integer("sysctl.proc_translated"), + hw_optional_arm64: sysctl_integer("hw.optional.arm64"), + }) +} + +/// Reads one NUL-terminated string sysctl. +#[cfg(target_os = "macos")] +fn sysctl_text(name: &str) -> Result> { + let key = std::ffi::CString::new(name).map_err(|_| recipe_error("sysctl name holds NUL"))?; + let mut buffer = [0_u8; 64]; + let mut length: libc::size_t = buffer.len(); + // SAFETY: `key` is a NUL-terminated C string, `buffer` is writable for + // `length` bytes, `length` points to its capacity, and no new value is set. + let status = unsafe { + libc::sysctlbyname( + key.as_ptr(), + buffer.as_mut_ptr().cast(), + &raw mut length, + std::ptr::null_mut(), + 0, + ) + }; + let bytes = buffer + .get(..length) + .filter(|_| status == 0) + .ok_or_else(|| recipe_error(format!("cannot read sysctl {name}")))?; + let text = bytes.split(|byte| *byte == 0).next().unwrap_or_default(); + String::from_utf8(text.to_vec()) + .map_err(|_| recipe_error(format!("sysctl {name} is not UTF-8"))) +} + +/// Reads one 32-bit integer sysctl, or `None` when it is absent. +#[cfg(target_os = "macos")] +fn sysctl_integer(name: &str) -> Option { + let key = std::ffi::CString::new(name).ok()?; + let mut value: i32 = 0; + let mut length: libc::size_t = std::mem::size_of::(); + // SAFETY: `key` is a NUL-terminated C string, `value` is a writable + // `i32` whose size `length` states, and no new value is set. + let status = unsafe { + libc::sysctlbyname( + key.as_ptr(), + (&raw mut value).cast(), + &raw mut length, + std::ptr::null_mut(), + 0, + ) + }; + (status == 0 && length == std::mem::size_of::()).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::super::expected_version_output; + use super::*; + use crate::source::CommitId; + + fn host( + version: &str, + architecture: &str, + translated: Option, + arm64: Option, + ) -> HostIdentity { + HostIdentity { + product_version: version.to_owned(), + architecture: architecture.to_owned(), + proc_translated: translated, + hw_optional_arm64: arm64, + } + } + + #[test] + fn host_identity_accepts_only_native_macos_15() { + let arm = ReleaseTarget::Aarch64AppleDarwin; + let intel = ReleaseTarget::X86_64AppleDarwin; + let cases = [ + ( + "native arm64", + arm, + host("15.5", "aarch64", Some(0), Some(1)), + true, + ), + ( + "arm64 point release", + arm, + host("15.6.1", "aarch64", Some(0), Some(1)), + true, + ), + ( + "translated arm64", + arm, + host("15.5", "aarch64", Some(1), Some(1)), + false, + ), + ( + "arm64 without translation report", + arm, + host("15.5", "aarch64", None, Some(1)), + false, + ), + ( + "arm64 on macOS 14", + arm, + host("14.7", "aarch64", Some(0), Some(1)), + false, + ), + ( + "arm64 on macOS 26", + arm, + host("26.0", "aarch64", Some(0), Some(1)), + false, + ), + ( + "arm64 asset on Intel", + arm, + host("15.5", "x86_64", None, None), + false, + ), + ( + "native Intel", + intel, + host("15.5", "x86_64", None, None), + true, + ), + ( + "Intel reporting zeros", + intel, + host("15.5", "x86_64", Some(0), Some(0)), + true, + ), + ( + "Intel under Rosetta", + intel, + host("15.5", "x86_64", Some(1), Some(1)), + false, + ), + ( + "Intel process on Apple silicon", + intel, + host("15.5", "x86_64", Some(0), Some(1)), + false, + ), + ( + "Intel asset on arm64", + intel, + host("15.5", "aarch64", Some(0), Some(1)), + false, + ), + ( + "malformed version", + intel, + host("15.x", "x86_64", None, None), + false, + ), + ( + "leading zero", + intel, + host("015.5", "x86_64", None, None), + false, + ), + ( + "Linux target", + ReleaseTarget::X86_64UnknownLinuxGnu, + host("15.5", "x86_64", None, None), + false, + ), + ]; + for (label, target, identity, accepted) in cases { + assert_eq!( + check_host_identity(target, &identity).is_ok(), + accepted, + "should decide the {label} host" + ); + } + } + + #[test] + fn startup_host_must_satisfy_the_accepted_artifact_minimum_before_execution() { + let host = host("15.5", "aarch64", Some(0), Some(1)); + check_host_minimum( + crate::policy::ReleaseTarget::Aarch64AppleDarwin, + &host, + crate::inspect::MacosVersion::new(13, 0), + ) + .expect("should accept native host above artifact minimum"); + assert!( + check_host_minimum( + crate::policy::ReleaseTarget::Aarch64AppleDarwin, + &host, + crate::inspect::MacosVersion::new(15, 6) + ) + .is_err(), + "should reject host below accepted artifact minimum" + ); + assert!( + check_host_minimum( + crate::policy::ReleaseTarget::X86_64AppleDarwin, + &host, + crate::inspect::MacosVersion::new(13, 0) + ) + .is_err(), + "should still reject another target CPU" + ); + } + + #[test] + fn startup_command_is_exact_and_outside_every_build_root() { + let layout = NativeStartupLayout { + copy_directory: Path::new("/startup/example/copy"), + home: Path::new("/startup/example/home"), + tmpdir: Path::new("/startup/example/tmp"), + build_roots: &[ + Path::new("/build/example/checkout"), + Path::new("/build/example/target"), + ], + }; + + let command = native_startup_command(&layout).expect("should derive the command"); + + assert_eq!(command.program, Path::new("/startup/example/copy/ts")); + assert_eq!(command.arguments, ["--version"]); + assert_eq!( + command.working_directory, + Path::new("/startup/example/copy") + ); + assert_eq!( + command.environment, + BTreeMap::from([ + ("HOME".to_owned(), "/startup/example/home".to_owned()), + ("LC_ALL".to_owned(), "C".to_owned()), + ("PATH".to_owned(), "/usr/bin:/bin".to_owned()), + ("TMPDIR".to_owned(), "/startup/example/tmp".to_owned()), + ]), + "should pass exactly the fixed environment" + ); + } + + #[test] + fn startup_layout_rejects_overlap_and_relative_paths() { + let roots = [ + Path::new("/build/example/checkout"), + Path::new("/build/example/target"), + ]; + let cases = [ + ( + "copy inside a build root", + "/build/example/target/copy", + "/startup/home", + "/startup/tmp", + ), + ( + "build roots below the copy", + "/build", + "/startup/home", + "/startup/tmp", + ), + ( + "home inside the copy", + "/startup/copy", + "/startup/copy/home", + "/startup/tmp", + ), + ( + "shared HOME and TMPDIR", + "/startup/copy", + "/startup/state", + "/startup/state", + ), + ( + "a relative copy", + "startup/copy", + "/startup/home", + "/startup/tmp", + ), + ( + "a dotted TMPDIR", + "/startup/copy", + "/startup/home", + "/startup/./tmp", + ), + ]; + for (label, copy, home, tmpdir) in cases { + let layout = NativeStartupLayout { + copy_directory: Path::new(copy), + home: Path::new(home), + tmpdir: Path::new(tmpdir), + build_roots: &roots, + }; + assert!( + native_startup_command(&layout).is_err(), + "should reject {label}" + ); + } + } + + #[test] + fn darwin_version_output_names_the_darwin_target() { + let commit = + CommitId::parse("0123456789abcdef0123456789abcdef01234567").expect("should parse"); + for target in [ + ReleaseTarget::Aarch64AppleDarwin, + ReleaseTarget::X86_64AppleDarwin, + ] { + assert_eq!( + expected_version_output(&commit, target), + format!( + "ts package={} source=0123456789abcdef0123456789abcdef01234567 target={} \ + variant=default\n", + env!("CARGO_PKG_VERSION"), + target.triple() + ), + "should expect exactly the owning build facts" + ); + } + } +} diff --git a/crates/trusted-server-release/src/recipe/tests.rs b/crates/trusted-server-release/src/recipe/tests.rs new file mode 100644 index 000000000..9250e199c --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests.rs @@ -0,0 +1,380 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; + +use super::ambient::ObservedAmbient; +use super::*; +use serde_json::Value; + +const COHORT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../scripts/release/release-cohort.json" +)); + +#[test] +fn unknown_asset_id_is_rejected() { + for name in [ + "", + "ts-aarch64-apple-darwin ", + "TS-AARCH64-APPLE-DARWIN", + "../ts-aarch64-apple-darwin", + "ts-riscv64-example-linux", + ] { + assert!( + AssetId::parse(name).is_err_and(|error| format!("{error:?}").contains("unknown asset")), + "should reject unknown asset id {name:?}" + ); + } +} + +#[test] +fn asset_selection_comes_from_fixed_table_not_json() { + let cohort: Value = serde_json::from_str(COHORT).expect("should parse tracked cohort"); + for entry in cohort["artifacts"] + .as_array() + .expect("should list cohort artifacts") + { + let name = entry["name"].as_str().expect("should name artifact"); + let Some(fixed) = FIXED_ARTIFACTS.iter().find(|fixed| fixed.name == name) else { + assert!( + AssetId::parse(name).is_err(), + "should not select ordinary-only Axum metadata as a controlled recipe" + ); + continue; + }; + let asset = AssetId::parse(name).expect("should accept every controlled destination asset"); + assert_eq!( + asset.artifact().package, + fixed.package, + "should use table package" + ); + assert_eq!(asset.artifact().bin, fixed.bin, "should use table binary"); + assert_eq!( + asset.artifact().target, + fixed.target, + "should use table target" + ); + let mut forged = entry.clone(); + forged["package"] = Value::from("example-forged-package"); + assert_eq!( + AssetId::parse(forged["name"].as_str().expect("should keep forged name")) + .expect("should still resolve") + .artifact() + .package, + fixed.package, + "should ignore JSON-supplied package" + ); + } +} + +#[test] +fn unknown_role_is_rejected() { + assert_eq!(Role::parse("A").expect("should accept A"), Role::Canonical); + assert_eq!( + Role::parse("B").expect("should accept B"), + Role::Reproduction + ); + for text in ["", "a", "b", "C", "AB", " A"] { + assert!( + Role::parse(text).is_err_and(|error| format!("{error:?}").contains("unknown role")), + "should reject role {text:?}" + ); + } +} + +#[test] +fn non_default_variant_is_rejected() { + check_variant("default").expect("should accept the default variant"); + for variant in ["", "Default", "minimal", "default "] { + assert!( + check_variant(variant).is_err_and(|error| format!("{error:?}").contains("variant")), + "should reject variant {variant:?}" + ); + } +} + +/// A fictional on-disk layout in which every request root is individually valid. +struct Workspace { + directory: tempfile::TempDir, + root: PathBuf, +} + +impl Workspace { + fn new() -> Self { + let directory = tempfile::tempdir().expect("should create workspace"); + let root = fs::canonicalize(directory.path()).expect("should canonicalize workspace"); + fs::create_dir_all(root.join("js/bundles")).expect("should create JS bundle root"); + fs::write(root.join("js/bundles/tsjs-core.js"), b"fictional module") + .expect("should write JS module"); + fs::write(root.join("js/tsjs-inventory.json"), b"fictional inventory") + .expect("should write JS inventory"); + fs::create_dir_all(root.join("materials/vendor")).expect("should create materials root"); + fs::create_dir(root.join("cargo-state")).expect("should create cargo state root"); + fs::create_dir(root.join("output")).expect("should create output root"); + Self { directory, root } + } + + fn path(&self, relative: &str) -> PathBuf { + self.root.join(relative) + } + + fn input<'a>(&'a self, paths: &'a [PathBuf; 6]) -> RecipeInput<'a> { + RecipeInput { + asset: "ts-aarch64-apple-darwin", + variant: "default", + role: Role::Canonical.spec_label(), + commit: COMMIT, + js_dir: &paths[0], + js_inventory: &paths[1], + materials: &paths[2], + cargo_state: &paths[3], + output: &paths[4], + target_dir: &paths[5], + } + } + + fn defaults(&self) -> [PathBuf; 6] { + [ + self.path("js/bundles"), + self.path("js/tsjs-inventory.json"), + self.path("materials"), + self.path("cargo-state"), + self.path("output"), + self.path("target"), + ] + } +} + +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn parse_error(paths: &[PathBuf; 6], workspace: &Workspace) -> String { + let error = match RecipeRequest::parse(&workspace.input(paths)) { + Ok(_) => panic!("should reject the request"), + Err(error) => error, + }; + format!("{error:?}") +} + +#[test] +fn relative_or_dotted_paths_are_rejected() { + let workspace = Workspace::new(); + RecipeRequest::parse(&workspace.input(&workspace.defaults())) + .expect("should accept the baseline layout"); + let bad_values = [ + PathBuf::from("relative/path"), + workspace.path("output/../output"), + workspace.path("./output"), + ]; + for index in 0..6 { + for bad in &bad_values { + let mut paths = workspace.defaults(); + paths[index] = bad.clone(); + assert!( + parse_error(&paths, &workspace).contains("absolute normal path"), + "should reject non-normal path {bad:?} at field {index}" + ); + } + } +} + +#[test] +fn existing_nonempty_output_root_is_rejected() { + let workspace = Workspace::new(); + fs::write(workspace.path("output/leftover"), b"previous attempt") + .expect("should write a leftover entry"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("cannot be reused"), + "should reject an output root with prior entries" + ); + let mut absent = workspace.defaults(); + absent[4] = workspace.path("missing-output"); + assert!( + parse_error(&absent, &workspace).contains("cannot be resolved"), + "should reject an output root that does not exist" + ); +} + +#[test] +fn symlinked_or_preexisting_materials_root_is_rejected() { + let workspace = Workspace::new(); + fs::create_dir(workspace.path("real-materials")).expect("should create alias target"); + symlink( + workspace.path("real-materials"), + workspace.path("linked-materials"), + ) + .expect("should create materials alias"); + let mut linked = workspace.defaults(); + linked[2] = workspace.path("linked-materials"); + assert!( + parse_error(&linked, &workspace).contains("symlink"), + "should reject a symlinked materials root" + ); + fs::write(workspace.path("materials/cache"), b"prepared earlier") + .expect("should populate materials"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("exactly the prepared vendor"), + "should reject a materials root holding more than the vendor directory" + ); +} + +#[test] +fn materials_root_must_hold_exactly_a_vendor_directory() { + let workspace = Workspace::new(); + RecipeRequest::parse(&workspace.input(&workspace.defaults())) + .expect("should accept the prepared layout"); + fs::remove_dir(workspace.path("materials/vendor")).expect("should remove vendor"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("exactly the prepared vendor"), + "should reject an empty materials root" + ); + fs::write(workspace.path("materials/vendor"), b"not a directory") + .expect("should write a vendor file"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("vendor directory"), + "should reject a vendor entry that is a file" + ); + fs::remove_file(workspace.path("materials/vendor")).expect("should remove vendor file"); + fs::create_dir_all(workspace.path("elsewhere")).expect("should create alias target"); + symlink( + workspace.path("elsewhere"), + workspace.path("materials/vendor"), + ) + .expect("should alias vendor"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("symlink"), + "should reject a symlinked vendor directory" + ); +} + +#[test] +fn reused_cargo_state_root_is_rejected() { + let workspace = Workspace::new(); + fs::create_dir(workspace.path("cargo-state/home")).expect("should create previous state"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("cannot be reused"), + "should reject a cargo state root with prior entries" + ); +} + +#[test] +fn reused_target_directory_is_rejected() { + let workspace = Workspace::new(); + fs::create_dir(workspace.path("target")).expect("should create a previous target"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("cannot be reused"), + "should reject a target directory that already exists" + ); +} + +#[test] +fn overlapping_roots_are_rejected() { + let workspace = Workspace::new(); + let cases: [(usize, &str); 6] = [ + (3, "output"), + (4, "cargo-state"), + (5, "output/target"), + (5, "cargo-state/target"), + (5, "materials/target"), + (5, "js/target"), + ]; + for (index, relative) in cases { + let mut paths = workspace.defaults(); + paths[index] = workspace.path(relative); + assert!( + parse_error(&paths, &workspace).contains("overlap"), + "should reject {relative} overlapping another root" + ); + } +} + +fn observed_canonical() -> ObservedAmbient { + ObservedAmbient { + umask: 0o022, + jobs: 4, + host_locale: "C".to_owned(), + host_timezone: "UTC".to_owned(), + child_locale: "C".to_owned(), + child_timezone: "UTC".to_owned(), + execution_uid: 501, + checkout_root: PathBuf::from("/example/release-canonical"), + started_at_seconds: 1_700_000_000, + } +} + +fn observed_reproduction() -> ObservedAmbient { + ObservedAmbient { + umask: 0o027, + jobs: 2, + host_locale: "en_US.UTF-8".to_owned(), + host_timezone: "Pacific/Auckland".to_owned(), + child_locale: "C".to_owned(), + child_timezone: "UTC".to_owned(), + execution_uid: 501, + checkout_root: PathBuf::from("/example/release-reproduction"), + started_at_seconds: 1_700_000_900, + } +} + +fn checked_canonical() -> CheckedAmbient { + CheckedAmbient::check( + Role::Canonical, + &AmbientProfile::CANONICAL, + observed_canonical(), + ) + .expect("should accept the baseline canonical ambient") +} + +fn checked_reproduction() -> CheckedAmbient { + CheckedAmbient::check( + Role::Reproduction, + &AmbientProfile::alternate_for_tests(), + observed_reproduction(), + ) + .expect("should accept the baseline reproduction ambient") +} + +mod ambient; +mod bind; +mod candidate; +#[cfg(target_os = "linux")] +mod cli; +mod cli_candidate; +mod compare; +mod complete; +mod complete_darwin; +mod darwin; +mod darwin_toolchain; +mod fixture; +#[cfg(target_os = "linux")] +mod full; +#[cfg(target_os = "macos")] +mod full_macos; +mod macos_candidate; +#[cfg(target_os = "macos")] +mod macos_cli; +mod native; +mod plan; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod real_fixture; +mod remap; +mod runtime; +mod sources; +mod tools; + +#[test] +fn symlinked_ancestor_of_target_parent_is_rejected() { + let workspace = Workspace::new(); + fs::create_dir(workspace.path("real-parent")).expect("should create alias target"); + symlink( + workspace.path("real-parent"), + workspace.path("linked-parent"), + ) + .expect("should create parent alias"); + fs::create_dir(workspace.path("real-parent/inner")).expect("should create inner parent"); + let mut paths = workspace.defaults(); + paths[5] = workspace.path("linked-parent/inner/target"); + assert!( + parse_error(&paths, &workspace).contains("symlink"), + "should reject a target below a symlinked ancestor" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/ambient.rs b/crates/trusted-server-release/src/recipe/tests/ambient.rs new file mode 100644 index 000000000..cfc37ba82 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/ambient.rs @@ -0,0 +1,299 @@ +use std::path::PathBuf; + +use serde_json::Value; + +use super::super::ambient::{ + AmbientClaim, AmbientRecord, Dimension, ObservedAmbient, check_profile_drift, +}; +use super::*; + +const TOOLS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../scripts/release/release-tools.json" +)); + +fn check_error(role: Role, profile: &AmbientProfile, observed: ObservedAmbient) -> String { + match CheckedAmbient::check(role, profile, observed) { + Ok(_) => panic!("should reject the ambient"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn reproduction_with_canonical_umask_is_rejected() { + checked_canonical(); + checked_reproduction(); + let reproduction_profile = AmbientProfile::alternate_for_tests(); + let wrong_umask = ObservedAmbient { + umask: 0o022, + ..observed_reproduction() + }; + assert!( + check_error(Role::Reproduction, &reproduction_profile, wrong_umask).contains("umask"), + "should reject the reproduction build observed under the canonical umask" + ); + let canonical_with_reproduction_umask = ObservedAmbient { + umask: 0o027, + ..observed_canonical() + }; + assert!( + check_error( + Role::Canonical, + &AmbientProfile::CANONICAL, + canonical_with_reproduction_umask + ) + .contains("umask"), + "should reject the canonical build observed under the reproduction umask" + ); + assert!( + check_error( + Role::Reproduction, + &AmbientProfile::CANONICAL, + observed_canonical() + ) + .contains("runnable profile"), + "should reject the reproduction build bound to the canonical profile" + ); + assert!( + check_error( + Role::Canonical, + &reproduction_profile, + observed_reproduction() + ) + .contains("runnable profile"), + "should reject the canonical build bound to an alternate profile" + ); + let closed = AmbientProfile::for_role(Role::Reproduction) + .expect_err("should keep the reproduction build closed"); + assert!( + format!("{closed:?}").contains("not qualified"), + "should name the missing qualification" + ); +} + +#[test] +fn observed_jobs_mismatch_is_rejected() { + let canonical_wrong = ObservedAmbient { + jobs: 2, + ..observed_canonical() + }; + assert!( + check_error(Role::Canonical, &AmbientProfile::CANONICAL, canonical_wrong).contains("jobs"), + "should reject the canonical build observed with reproduction jobs" + ); + let reproduction_wrong = ObservedAmbient { + jobs: 8, + ..observed_reproduction() + }; + assert!( + check_error( + Role::Reproduction, + &AmbientProfile::alternate_for_tests(), + reproduction_wrong + ) + .contains("jobs"), + "should reject the reproduction build observed with unrelated jobs" + ); +} + +fn claim_error( + canonical: &CheckedAmbient, + reproduction: &CheckedAmbient, + claim: &AmbientClaim, +) -> String { + match AmbientRecord::pair(canonical, reproduction, claim) { + Ok(_) => panic!("should reject the pairing"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn normalized_locale_reported_as_varied_is_rejected() { + let record = AmbientRecord::pair( + &checked_canonical(), + &checked_reproduction(), + &AmbientClaim::standard(), + ) + .expect("should accept the standard claim"); + assert_eq!( + record.claim(), + &AmbientClaim::standard(), + "should keep the verified placement" + ); + for dimension in [Dimension::Locale, Dimension::Timezone] { + let mut claim = AmbientClaim::standard(); + claim.normalized.remove(&dimension); + claim.varied.insert(dimension); + assert!( + claim_error(&checked_canonical(), &checked_reproduction(), &claim) + .contains("misreports"), + "should reject {dimension} claimed as varied" + ); + } + let leaking_child = ObservedAmbient { + child_locale: "en_US.UTF-8".to_owned(), + ..observed_reproduction() + }; + assert!( + check_error( + Role::Reproduction, + &AmbientProfile::alternate_for_tests(), + leaking_child + ) + .contains("child locale"), + "should reject a compiler child that sees the host locale" + ); +} + +#[test] +fn unsupported_execution_user_claimed_as_varied_is_rejected() { + let mut claim = AmbientClaim::standard(); + claim.unsupported.remove(&Dimension::ExecutionUser); + claim.varied.insert(Dimension::ExecutionUser); + assert!( + claim_error(&checked_canonical(), &checked_reproduction(), &claim).contains("misreports"), + "should reject an unqualified user claimed as varied" + ); + let other_user = CheckedAmbient::check( + Role::Reproduction, + &AmbientProfile::alternate_for_tests(), + ObservedAmbient { + execution_uid: 502, + ..observed_reproduction() + }, + ) + .expect("should check the alternate user in isolation"); + assert!( + claim_error(&checked_canonical(), &other_user, &AmbientClaim::standard()) + .contains("execution user"), + "should reject an actually different unqualified user" + ); +} + +#[test] +fn omitted_ambient_dimension_is_rejected() { + for dimension in Dimension::ALL { + let mut claim = AmbientClaim::standard(); + claim.varied.remove(&dimension); + claim.normalized.remove(&dimension); + claim.unsupported.remove(&dimension); + assert!( + claim_error(&checked_canonical(), &checked_reproduction(), &claim).contains("omits"), + "should reject a claim omitting {dimension}" + ); + } + let mut duplicated = AmbientClaim::standard(); + duplicated.normalized.insert(Dimension::Jobs); + assert!( + claim_error(&checked_canonical(), &checked_reproduction(), &duplicated) + .contains("misreports"), + "should reject a dimension placed twice" + ); +} + +#[test] +fn identical_canonical_and_reproduction_checkout_roots_are_rejected() { + for root in [ + "/example/release-canonical", + "/example/release-canonical/nested", + "/example", + ] { + let overlapping = CheckedAmbient::check( + Role::Reproduction, + &AmbientProfile::alternate_for_tests(), + ObservedAmbient { + checkout_root: PathBuf::from(root), + ..observed_reproduction() + }, + ) + .expect("should check the alternate root in isolation"); + assert!( + claim_error( + &checked_canonical(), + &overlapping, + &AmbientClaim::standard() + ) + .contains("overlap"), + "should reject checkout root {root} overlapping the canonical build" + ); + } + let same_start = CheckedAmbient::check( + Role::Reproduction, + &AmbientProfile::alternate_for_tests(), + ObservedAmbient { + started_at_seconds: observed_canonical().started_at_seconds, + ..observed_reproduction() + }, + ) + .expect("should check the alternate start in isolation"); + assert!( + claim_error(&checked_canonical(), &same_start, &AmbientClaim::standard()) + .contains("StartTime"), + "should reject builds that did not start at different times" + ); +} + +#[test] +fn rust_profile_drift_from_release_tools_json_is_rejected() { + let tools: Value = serde_json::from_str(TOOLS).expect("should parse tracked tools"); + let recipes = tools["recipes"].as_array().expect("should list recipes"); + let mut checked = 0; + for recipe in recipes + .iter() + .filter(|recipe| recipe["script"] == "scripts/release/build.sh") + { + check_profile_drift(&recipe["settings"]) + .expect("should accept the tracked ambient profiles"); + checked += 1; + for (profile, field, value) in [ + (Role::Canonical.spec_label(), "umask", Value::from("027")), + (Role::Canonical.spec_label(), "jobs", Value::from(2)), + (Role::Reproduction.spec_label(), "jobs", Value::from(4)), + (Role::Reproduction.spec_label(), "umask", Value::from("022")), + (Role::Reproduction.spec_label(), "locale", Value::from("C")), + ] { + let mut drifted = recipe["settings"].clone(); + drifted["ambient_profiles"][profile][field] = value; + assert!( + check_profile_drift(&drifted) + .is_err_and(|error| format!("{error:?}").contains(profile)), + "should reject drifted {profile}.{field}" + ); + } + let mut missing = recipe["settings"].clone(); + missing["ambient_profiles"] + .as_object_mut() + .expect("should hold profiles") + .remove(Role::Reproduction.spec_label()); + assert!( + check_profile_drift(&missing).is_err_and(|error| { + format!("{error:?}") + .contains(&format!("profile {}", Role::Reproduction.spec_label())) + }), + "should reject a missing reproduction profile" + ); + } + assert_eq!(checked, 4, "should check every artifact recipe"); +} + +#[test] +fn reproduction_with_canonical_locale_or_timezone_is_rejected() { + let same_locale = AmbientProfile::alternate_with_host_for_tests("C", "Pacific/Auckland"); + let observed = ObservedAmbient { + host_locale: "C".to_owned(), + ..observed_reproduction() + }; + assert!( + check_error(Role::Reproduction, &same_locale, observed).contains("runnable profile"), + "should reject a reproduction build that keeps the canonical locale" + ); + let same_timezone = AmbientProfile::alternate_with_host_for_tests("en_US.UTF-8", "UTC"); + let observed = ObservedAmbient { + host_timezone: "UTC".to_owned(), + ..observed_reproduction() + }; + assert!( + check_error(Role::Reproduction, &same_timezone, observed).contains("runnable profile"), + "should reject a reproduction build that keeps the canonical timezone" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/bind.rs b/crates/trusted-server-release/src/recipe/tests/bind.rs new file mode 100644 index 000000000..7e5c59369 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/bind.rs @@ -0,0 +1,350 @@ +use std::fs; + +use super::fixture::*; +use super::*; +use crate::boundary::BoundaryRoots; +#[cfg(target_os = "macos")] +use crate::boundary::{Boundary, DenialProof}; + +#[test] +fn js_handoff_for_other_source_commit_is_rejected() { + Fixture::new(COMMIT) + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the matching handoff"); + let other = Fixture::new(OTHER_COMMIT); + assert!( + bind_error(other.bind("ts-aarch64-apple-darwin", Role::Canonical)) + .contains("another source commit"), + "should reject a JS handoff built from another commit" + ); + assert!( + !other.base.join("target").exists(), + "should not create the target after a rejection" + ); + let fixture = Fixture::new(COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + OTHER_COMMIT, + ); + assert!( + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)) + .contains("another source commit"), + "should reject a request naming another commit" + ); +} + +#[test] +fn js_handoff_mutated_after_completion_is_rejected() { + let changed = Fixture::new(COMMIT); + let request = changed.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); + fs::write(changed.retained().join("bundles/tsjs-core.js"), b"tampered") + .expect("should mutate a retained module"); + assert!( + bind_error(changed.bind_with(request, Role::Canonical, COMMIT)) + .contains("no longer rehashes"), + "should reject a mutated retained module" + ); + let extra = Fixture::new(COMMIT); + let request = extra.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); + fs::write(extra.retained().join("unrecorded"), b"extra").expect("should add an entry"); + assert!( + bind_error(extra.bind_with(request, Role::Canonical, COMMIT)) + .contains("no longer rehashes"), + "should reject an unrecorded retained entry" + ); + assert!( + !changed.base.join("target").exists(), + "should not create the target after a rejection" + ); +} + +#[test] +fn request_for_other_js_directory_or_role_is_rejected() { + let fixture = Fixture::new(COMMIT); + let foreign = Fixture::new(COMMIT); + let request = foreign.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); + assert!( + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)).contains("does not name"), + "should reject a request naming another JS handoff directory" + ); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Reproduction.spec_label(), + COMMIT, + ); + assert!( + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)).contains("ambient role"), + "should reject an ambient of another role" + ); +} + +#[test] +fn bind_creates_fresh_target_and_private_cargo_state() { + let fixture = Fixture::new(COMMIT); + fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the recipe"); + for relative in [ + "target", + "cargo-state/home", + "cargo-state/cargo-home", + "cargo-state/tmp", + ] { + assert!( + fixture.base.join(relative).is_dir(), + "should create {relative}" + ); + } + assert_eq!( + fs::read_dir(fixture.base.join("output")) + .expect("should list output") + .count(), + 0, + "should leave the unmounted output root empty" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn execute_rejects_boundary_with_other_roots() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the recipe"); + let other = BoundaryRoots::new( + &[fixture.base.join("other-source")], + &[fixture.base.join("tools")], + &[fixture.base.join("other-work")], + ) + .expect("should build unrelated roots"); + let boundary = Boundary::mac_sandbox_for_tests(other).expect("should configure, not run"); + let error = prepared + .execute(&boundary, &DenialProof::for_tests()) + .expect_err("should reject a boundary over other roots"); + assert!( + format!("{error:?}").contains("outside the boundary roots"), + "should reject plan paths outside the other roots: {error:?}" + ); +} + +#[test] +fn qualified_rust_tools_constructor_is_uninhabited() { + let never: fn(QualifiedRustTools) -> ! = |qualification| match qualification {}; + let _constructor: fn(&Path, QualifiedRustTools) -> SelectedRustTools = + SelectedRustTools::qualified; + let _ = never; + let closed = AmbientProfile::for_role(Role::Reproduction) + .expect_err("should keep the alternate ambient closed"); + assert!( + format!("{closed:?}").contains("not qualified"), + "should name the missing qualification" + ); +} + +#[test] +fn ordinary_ci_selection_carries_no_release_authority() { + let fixture = Fixture::new(COMMIT); + let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); + assert_eq!( + tools.authority(), + Authority::Unqualified, + "should never report a test selection as qualified" + ); + assert_eq!( + ToolSelection::OrdinaryCi.authority(), + Authority::Unqualified, + "should report the ordinary CI selection as unqualified" + ); +} + +#[test] +fn root_filled_between_parse_and_bind_is_rejected() { + for (relative, label) in [("cargo-state", "cargo state"), ("output", "output")] { + let fixture = Fixture::new(COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); + fs::write( + fixture.base.join(relative).join("late"), + b"written after parse", + ) + .expect("should fill the root after parsing"); + let error = bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)); + assert!( + error.contains(label) && error.contains("cannot be reused"), + "should reject a {label} root filled after parsing" + ); + assert!( + !fixture.base.join("target").exists(), + "should not create the target after a rejection" + ); + } + let fixture = Fixture::new(COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); + fs::create_dir(fixture.base.join("target")).expect("should create the target after parse"); + assert!( + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)) + .contains("cannot be reused"), + "should reject a target created after parsing" + ); +} + +#[test] +fn materials_changed_or_foreign_after_preparation_are_rejected() { + let fixture = Fixture::new(COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); + let materials = fixture.materials(COMMIT); + fs::write( + fixture + .base + .join("materials/vendor/example-1.0.0/Cargo.toml"), + b"tampered after preparation", + ) + .expect("should edit a vendored file"); + let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); + assert!( + bind_error(fixture.bind_parts(request, Role::Canonical, COMMIT, tools, materials)) + .contains("no longer rehash"), + "should reject materials edited after preparation" + ); + assert!( + !fixture.base.join("target").exists(), + "should not create the target after a rejection" + ); + + let fixture = Fixture::new(COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); + let foreign = fixture.materials(OTHER_COMMIT); + let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); + assert!( + bind_error(fixture.bind_parts(request, Role::Canonical, COMMIT, tools, foreign)) + .contains("another source commit"), + "should reject materials derived from another commit" + ); +} + +#[test] +fn tools_selected_for_another_target_are_rejected() { + let fixture = Fixture::new(COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); + let tools = fixture.tools_for(ReleaseTarget::X86_64UnknownLinuxGnu); + let materials = fixture.materials(COMMIT); + assert!( + bind_error(fixture.bind_parts(request, Role::Canonical, COMMIT, tools, materials)) + .contains("another target"), + "should reject tools whose standard library was checked for another target" + ); +} + +#[test] +fn target_inside_checkout_is_rejected_before_creation() { + let fixture = Fixture::new(COMMIT); + let inside = fixture.base.join("checkout-canonical/target"); + let request = fixture.request_at( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + &inside, + ); + assert!( + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)).contains("overlap"), + "should reject a target inside the checkout root" + ); + assert!(!inside.exists(), "should create nothing in the checkout"); + let fixture = Fixture::new(COMMIT); + let inside_tools = fixture.base.join("tools/target"); + let request = fixture.request_at( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + &inside_tools, + ); + assert!( + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)).contains("overlap"), + "should reject a target inside the tools root" + ); + assert!(!inside_tools.exists(), "should create nothing in the tools"); +} + +#[test] +fn boundary_roots_mount_materials_read_only_state_and_target_writable_output_unmounted() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the recipe"); + let expected = BoundaryRoots::new( + &[ + fixture.base.join("checkout-canonical"), + fixture.retained(), + fixture.base.join("materials"), + ], + &[fixture.base.join("tools")], + &[ + fixture.base.join("cargo-state"), + fixture.base.join("target"), + ], + ) + .and_then(|roots| roots.with_system(&[fixture.developer_dir()])) + .expect("should build the expected roots"); + assert_eq!( + prepared.boundary_roots().expect("should derive roots"), + expected, + "should mount exactly the planned roles" + ); + assert_eq!( + expected.system(), + [fixture.developer_dir()], + "should add the Apple developer directory as the only system root" + ); + let all: Vec<_> = expected + .entries() + .map(|(_, _, path)| path.to_owned()) + .collect(); + assert!( + !all.iter() + .any(|path| path.starts_with(fixture.base.join("output"))), + "should not mount the output root" + ); + assert_eq!( + expected.writable().len(), + 2, + "should expose exactly two writable roots" + ); + assert!( + expected + .immutable() + .contains(&fixture.base.join("materials")), + "should mount the vendor materials read-only" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/candidate.rs b/crates/trusted-server-release/src/recipe/tests/candidate.rs new file mode 100644 index 000000000..bf429cb3a --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/candidate.rs @@ -0,0 +1,830 @@ +//! Ordinary CI candidate support: the Cargo fixture description, the explicit +//! container settings and the measurements the real Linux fixtures print. +//! +//! Nothing here is release qualification. The fixture is acquired without +//! authentication, the toolchain and image are unqualified, and every +//! measurement says so. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use serde_json::{Map, Value, json}; +use sha2::Sha256; + +use super::super::ambient::Dimension; +use super::super::compare::FastlyEqualityChecked; +use super::*; +use crate::boundary::DockerSettings; +use crate::process::ProcessLimits; + +/// Schema version of `fixture.json` and of every measurement. +pub(super) const SCHEMA_VERSION: u64 = 1; +/// Kind of the Linux Cargo fixture description. +const FIXTURE_KIND: &str = "ordinary-ci-cargo-fixture"; +/// Kind of the macOS Cargo fixture description, which names no image. +const MACOS_FIXTURE_KIND: &str = "ordinary-ci-macos-cargo-fixture"; + +/// Which host a Cargo fixture was prepared for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum FixtureHost { + /// The Linux container job; the fixture names a local builder image ID. + LinuxContainer, + /// A macOS sandbox job; the fixture names no image (`image_id: null`). + MacosSandbox, +} +/// How the fixture inputs were acquired. +const ACQUISITION: &str = "unauthenticated-ordinary-ci"; + +/// Every field `fixture.json` must hold, and nothing else. +const FIXTURE_FIELDS: [&str; 10] = [ + "acquisition", + "cargo_lock_sha256", + "comparison_manifests", + "git", + "image_id", + "kind", + "registry", + "rustc_version", + "schema_version", + "source_commit", +]; + +/// Container process limit of an ordinary CI Cargo build. +const CARGO_PIDS_LIMIT: u32 = 4096; +/// Container memory of an ordinary CI Cargo build. +const CARGO_MEMORY_BYTES: u64 = 8 * 1024 * 1024 * 1024; +/// Most CPUs an ordinary CI Cargo container receives (a 4-vCPU runner). +const CARGO_MAX_CPUS: u32 = 4; +/// Private `/tmp` of the Cargo container; Cargo's own `TMPDIR` is a root. +const CARGO_TMPFS_BYTES: u64 = 512 * 1024 * 1024; + +/// One retained Git object directory for a locked Git source. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct GitObjects { + pub(super) selector: String, + pub(super) revision: String, + pub(super) objects: PathBuf, +} + +/// The manifest `cargo vendor` wrote for one locked Git package. +/// +/// It is a differential comparison fixture only: the producer never vendors +/// it, and it names which locked Git packages to supply closures for. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct ComparisonManifest { + pub(super) name: String, + pub(super) version: String, + pub(super) selector: String, + pub(super) cargo_vendor_manifest: PathBuf, +} + +/// The parsed, unauthenticated ordinary CI Cargo fixture. +#[derive(Debug)] +pub(super) struct CargoFixture { + pub(super) root: PathBuf, + pub(super) source_commit: String, + pub(super) cargo_lock_sha256: String, + pub(super) host: FixtureHost, + pub(super) image_id: Option, + pub(super) registry: BTreeMap<(String, String), PathBuf>, + pub(super) git: Vec, + pub(super) comparison_manifests: Vec, +} + +fn fixture_error(detail: impl std::fmt::Display) -> Report { + recipe_error(format!("cargo fixture: {detail}")) +} + +fn lower_hex(text: &str, length: usize) -> bool { + text.len() == length + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Returns the string field `name` of `value`. +fn text<'a>(value: &'a Value, name: &str) -> Result<&'a str, Report> { + value[name] + .as_str() + .filter(|text| !text.is_empty()) + .ok_or_else(|| fixture_error(format!("{name} is not a non-empty string"))) +} + +/// Joins a relative fixture path made only of normal components. +fn below(root: &Path, value: &Value, name: &str) -> Result> { + let relative = Path::new(text(value, name)?); + let normal = relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + && relative.components().next().is_some(); + if !normal { + return Err(fixture_error(format!( + "{name} must be a relative path of normal components" + ))); + } + Ok(root.join(relative)) +} + +/// Returns the array field `name` of `value`. +fn entries<'a>(value: &'a Value, name: &str) -> Result<&'a [Value], Report> { + value[name] + .as_array() + .map(Vec::as_slice) + .ok_or_else(|| fixture_error(format!("{name} is not an array"))) +} + +impl CargoFixture { + /// Parses `fixture.json` of the fixture at `root`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for another schema, kind or + /// acquisition, a missing or extra field, a malformed commit or digest, + /// a Linux fixture without a local image ID or a macOS fixture with any + /// image ID, a path that is absolute or dotted, a repeated registry + /// identity, no Git source, or a comparison manifest whose selector names + /// no retained Git source. + pub(super) fn parse(root: &Path, document: &Value) -> Result> { + let fields: BTreeSet<&str> = document + .as_object() + .ok_or_else(|| fixture_error("description is not an object"))? + .keys() + .map(String::as_str) + .collect(); + if fields != BTreeSet::from(FIXTURE_FIELDS) { + return Err(fixture_error("description fields differ from the schema")); + } + let host = match text(document, "kind")? { + FIXTURE_KIND => FixtureHost::LinuxContainer, + MACOS_FIXTURE_KIND => FixtureHost::MacosSandbox, + _ => return Err(fixture_error("unsupported schema, kind or acquisition")), + }; + if document["schema_version"].as_u64() != Some(SCHEMA_VERSION) + || text(document, "acquisition")? != ACQUISITION + { + return Err(fixture_error("unsupported schema, kind or acquisition")); + } + let source_commit = text(document, "source_commit")?.to_owned(); + let cargo_lock_sha256 = text(document, "cargo_lock_sha256")?.to_owned(); + let image_id = match (host, &document["image_id"]) { + (FixtureHost::LinuxContainer, Value::String(id)) + if id + .strip_prefix("sha256:") + .is_some_and(|digest| lower_hex(digest, 64)) => + { + Some(id.clone()) + } + (FixtureHost::MacosSandbox, Value::Null) => None, + _ => { + return Err(fixture_error( + "a Linux fixture needs a local image ID and a macOS fixture none", + )); + } + }; + if !lower_hex(&source_commit, 40) || !lower_hex(&cargo_lock_sha256, 64) { + return Err(fixture_error("malformed commit or lock digest")); + } + let git = entries(document, "git")? + .iter() + .map(|entry| { + Ok(GitObjects { + selector: text(entry, "selector")?.to_owned(), + revision: text(entry, "revision")?.to_owned(), + objects: below(root, entry, "objects")?, + }) + }) + .collect::, Report>>()?; + if git.is_empty() || git.iter().any(|entry| !lower_hex(&entry.revision, 40)) { + return Err(fixture_error("needs Git sources with full revisions")); + } + Ok(Self { + registry: registry(root, document)?, + comparison_manifests: comparison_manifests(root, document, &git)?, + root: root.to_owned(), + source_commit, + cargo_lock_sha256, + host, + image_id, + git, + }) + } + + /// The local builder image ID of a Linux container fixture. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for a macOS fixture, which names none. + pub(super) fn container_image_id(&self) -> Result<&str, Report> { + self.image_id + .as_deref() + .ok_or_else(|| fixture_error("a macOS fixture names no container image")) + } + + /// The private single-link Rust tool root. + pub(super) fn tools(&self) -> PathBuf { + self.root.join("tools") + } + + /// The staged producer probe inside the tool root. + pub(super) fn probe(&self) -> PathBuf { + self.root.join("tools/probe") + } +} + +/// Parses the registry originals, keyed by exact locked identity. +fn registry( + root: &Path, + document: &Value, +) -> Result, Report> { + let mut registry = BTreeMap::new(); + for entry in entries(document, "registry")? { + let key = ( + text(entry, "name")?.to_owned(), + text(entry, "version")?.to_owned(), + ); + if registry + .insert(key.clone(), below(root, entry, "path")?) + .is_some() + { + return Err(fixture_error(format!( + "registry repeats {} {}", + key.0, key.1 + ))); + } + } + Ok(registry) +} + +/// Parses the `cargo vendor` comparison manifests; each must name a retained +/// Git source. +fn comparison_manifests( + root: &Path, + document: &Value, + git: &[GitObjects], +) -> Result, Report> { + entries(document, "comparison_manifests")? + .iter() + .map(|entry| { + let pair = ComparisonManifest { + name: text(entry, "name")?.to_owned(), + version: text(entry, "version")?.to_owned(), + selector: text(entry, "selector")?.to_owned(), + cargo_vendor_manifest: below(root, entry, "cargo_vendor_manifest")?, + }; + if !git.iter().any(|source| source.selector == pair.selector) { + return Err(fixture_error(format!( + "comparison manifest {} names no retained Git source", + pair.name + ))); + } + Ok(pair) + }) + .collect() +} + +/// Explicit container settings of an ordinary CI Cargo build. +/// +/// These are fixture settings for the ordinary CI runner, not production +/// pins: a qualified builder environment selects its own. +pub(super) fn cargo_docker_settings(user: (u32, u32), parallelism: u32) -> DockerSettings { + DockerSettings { + client: PathBuf::from("/usr/bin/docker"), + cpus: parallelism.clamp(1, CARGO_MAX_CPUS), + memory_bytes: CARGO_MEMORY_BYTES, + pids_limit: CARGO_PIDS_LIMIT, + tmpfs_bytes: CARGO_TMPFS_BYTES, + user, + stop_timeout_seconds: 10, + host_limits: ProcessLimits { + wall: Duration::from_secs(300), + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + }, + } +} + +fn role_text(role: Role) -> &'static str { + match role { + Role::Canonical => "canonical", + Role::Reproduction => "reproduction", + } +} + +/// The ordinary CI measurement of one confined Cargo build. +pub(super) fn cargo_measurement(completed: &RecipeCompleted) -> Value { + let artifact = completed.artifact(); + let [js_inventory, js_settings, js_tools] = completed.js_digests(); + json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-confined-cargo-build", + "asset": completed.asset().artifact().name, + "role": role_text(completed.role()), + "source_commit": completed.commit().as_str(), + "cargo_lock_sha256": completed.cargo_lock_sha256(), + "artifact": { + "name": artifact.name(), + "sha256": artifact.sha256(), + "size": artifact.size(), + }, + "rust_tools_digest": completed.rust_tools_digest(), + "materials_digest": completed.materials_digest(), + "plan_digest": completed.plan_digest(), + "js_inventory_sha256": js_inventory, + "js_settings_sha256": js_settings, + "js_tools_sha256": js_tools, + "boundary_fingerprint": hex::encode(completed.boundary_fingerprint()), + "descendant_cleanup": completed.descendant_cleanup().to_string(), + "stdout_sha256": completed.stdout_sha256(), + "stderr_sha256": completed.stderr_sha256(), + "cargo_observations": completed.cargo_observations_summary(), + "elapsed_seconds": completed.elapsed().as_secs(), + "normalized_manifest_count": completed.normalized_manifest_digests().len(), + "normalized_manifest_digests": completed.normalized_manifest_digests(), + "derived_manifest_digests": completed.derived_manifest_digests().len(), + "release_qualification": false, + }) +} + +/// The ambient conditions one build ran under, as the measurement states them. +/// +/// Umask and jobs are the profile values the plan enforced on the child, and +/// the host locale and timezone are profile labels: the child always ran +/// with the normalized `C` locale and `UTC` timezone. +fn ambient_measurement(completed: &RecipeCompleted) -> Value { + let ambient = completed.ambient(); + let observed = ambient.observed(); + json!({ + "role": role_text(ambient.role()), + "umask": format!("{:03o}", observed.umask), + "jobs": observed.jobs, + "checkout_root": observed.checkout_root.to_string_lossy(), + "ambient_started_at_seconds": observed.started_at_seconds, + "run_started_at_seconds": completed.started_at_seconds(), + "execution_uid": observed.execution_uid, + "host_locale_label": observed.host_locale, + "host_timezone_label": observed.host_timezone, + "child_locale": observed.child_locale, + "child_timezone": observed.child_timezone, + "observation": "profile-enforced-not-independently-observed", + }) +} + +/// The ordinary CI measurement of the Fastly canonical/reproduction final-byte +/// comparison. +pub(super) fn equality_measurement( + canonical: &RecipeCompleted, + reproduction: &RecipeCompleted, + checked: &FastlyEqualityChecked, +) -> Value { + let claim = checked.ambient().claim(); + let names = |set: &BTreeSet| -> Vec { + set.iter().map(ToString::to_string).collect() + }; + let build = |completed: &RecipeCompleted| { + let mut value = cargo_measurement(completed); + value["ambient"] = ambient_measurement(completed); + value + }; + json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-fastly-canonical-reproduction-equality", + "asset": checked.asset().artifact().name, + "source_commit": canonical.commit().as_str(), + "equality": true, + "artifact": { + "sha256": checked.sha256(), + "size": checked.size(), + }, + "builds": { + "canonical": build(canonical), + "reproduction": build(reproduction), + }, + "ambient_claim": { + "varied": names(&claim.varied), + "normalized": names(&claim.normalized), + "unsupported": names(&claim.unsupported), + }, + "release_qualification": false, + }) +} + +/// Names the dependency build cache the CI job restored, or `miss`. +const CI_BUILD_CACHE_VARIABLE: &str = "TS_RELEASE_CI_BUILD_CACHE"; +/// What the provenance records when the job reported no cache outcome. +const UNREPORTED_BUILD_CACHE: &str = "unreported"; + +/// How this run's producer probe and test binary were built, as ordinary CI +/// evidence only. +/// +/// CI jobs may restore third-party dependency artifacts written by an +/// earlier main-branch run, which then link into the staged probe and this +/// test binary. Recording both digests and the restored cache key makes that +/// trust root auditable. A cache never makes the run release qualification: +/// every measurement carrying this still states `release_qualification: +/// false`. +/// +/// # Panics +/// +/// Panics when the staged probe or the running test binary cannot be read, +/// or the cache variable is not UTF-8. +pub(super) fn ci_build_provenance(fixture: &CargoFixture) -> Value { + let probe = std::fs::read(fixture.probe()).expect("should read the staged producer probe"); + let test_binary = std::env::current_exe() + .and_then(std::fs::read) + .expect("should read the running test binary"); + let cache = std::env::var_os(CI_BUILD_CACHE_VARIABLE).map(|value| { + value + .into_string() + .expect("should name the CI build cache in UTF-8") + }); + build_provenance(&probe, &test_binary, cache.as_deref()) +} + +/// The provenance of explicit probe and test binary bytes and cache outcome. +fn build_provenance(probe: &[u8], test_binary: &[u8], cache: Option<&str>) -> Value { + json!({ + "producer_probe_sha256": hex::encode(Sha256::digest(probe)), + "test_binary_sha256": hex::encode(Sha256::digest(test_binary)), + "build_cache": cache + .filter(|cache| !cache.is_empty()) + .unwrap_or(UNREPORTED_BUILD_CACHE), + }) +} + +/// The ordinary CI measurement of the Cargo denial fixture. +pub(super) fn denial_measurement( + proof_errno: i32, + observed: &BTreeMap, + control: &RecipeCompleted, +) -> Value { + let cases: Map = observed + .iter() + .map(|(case, errno)| (case.clone(), json!({ "errno": errno }))) + .collect(); + json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-cargo-denial", + "proof_errno": proof_errno, + "attempts": cases, + "control": { + "asset": control.asset().artifact().name, + "sha256": control.artifact().sha256(), + "size": control.artifact().size(), + }, + "release_qualification": false, + }) +} + +/// Returns the errno one denial fixture case reported in `text`. +/// +/// The fixture prints `TS_DENIAL case= errno=[]`, or +/// `... child errno=[]` from the re-executed grandchild. A connected +/// attempt, a child that never reported or any other text yields `None`. +pub(super) fn reported_errno(case: &str, text: &str) -> Option { + let marker = format!("TS_DENIAL case={case} "); + let start = text.find(&marker)? + marker.len(); + let rest = &text[start..]; + let rest = rest.strip_prefix("child ").unwrap_or(rest); + let digits = rest.strip_prefix("errno=[")?; + let end = digits.find(']')?; + digits[..end].parse().ok() +} + +#[test] +fn fixture_description_parses_relative_inputs() { + let root = Path::new("/example/fixture"); + let parsed = CargoFixture::parse(root, &description()).expect("should parse the description"); + assert_eq!( + parsed + .registry + .get(&("example".to_owned(), "1.0.0".to_owned())), + Some(&root.join("crates/example-1.0.0.crate")), + "should key registry originals by locked identity" + ); + assert_eq!( + parsed.git[0].objects, + root.join("git/0/objects"), + "should resolve Git objects below the root" + ); + assert_eq!( + parsed.comparison_manifests[0].cargo_vendor_manifest, + root.join("cargo-vendor/example-git-0.1.0/Cargo.toml"), + "should resolve the comparison manifest below the root" + ); + assert_eq!( + parsed.probe(), + root.join("tools/probe"), + "should fix the probe" + ); +} + +/// One in-place change to a fixture description. +type Edit = fn(&mut Value); + +#[test] +fn fixture_description_rejects_unsafe_or_foreign_inputs() { + let root = Path::new("/example/fixture"); + let cases: [(&str, Edit); 8] = [ + ("absolute path", |value| { + value["registry"][0]["path"] = json!("/example/elsewhere.crate"); + }), + ("dotted path", |value| { + value["git"][0]["objects"] = json!("git/../../objects"); + }), + ("extra field", |value| value["extra"] = json!(true)), + ("kind", |value| { + value["kind"] = json!("release-qualification") + }), + ("acquisition", |value| { + value["acquisition"] = json!("authenticated"); + }), + ("image", |value| value["image_id"] = json!("example:latest")), + ("duplicate", |value| { + let first = value["registry"][0].clone(); + value["registry"] + .as_array_mut() + .expect("should hold registry entries") + .push(first); + }), + ("orphan comparison manifest", |value| { + value["comparison_manifests"][0]["selector"] = + json!("git+https://example.com/other?tag=v2"); + }), + ]; + for (label, edit) in cases { + let mut value = description(); + edit(&mut value); + assert!( + CargoFixture::parse(root, &value).is_err(), + "should reject a description with a bad {label}" + ); + } +} + +#[test] +fn fixture_kind_decides_whether_an_image_is_named() { + let root = Path::new("/example/fixture"); + let linux = CargoFixture::parse(root, &description()).expect("should parse Linux"); + assert_eq!(linux.host, FixtureHost::LinuxContainer); + assert_eq!( + linux.container_image_id().ok(), + Some(format!("sha256:{}", "b".repeat(64)).as_str()), + "should keep the Linux builder image ID" + ); + + let mut macos = description(); + macos["kind"] = json!("ordinary-ci-macos-cargo-fixture"); + macos["image_id"] = Value::Null; + let parsed = CargoFixture::parse(root, &macos).expect("should parse macOS"); + assert_eq!(parsed.host, FixtureHost::MacosSandbox); + assert_eq!(parsed.image_id, None, "should name no image"); + assert!( + parsed.container_image_id().is_err(), + "should refuse a container image for a macOS fixture" + ); + + let cases: [(&str, &str, Value); 4] = [ + ( + "macOS with an image", + "ordinary-ci-macos-cargo-fixture", + json!(format!("sha256:{}", "b".repeat(64))), + ), + ( + "Linux without an image", + "ordinary-ci-cargo-fixture", + Value::Null, + ), + ( + "macOS with an empty image", + "ordinary-ci-macos-cargo-fixture", + json!(""), + ), + ( + "an unknown kind", + "ordinary-ci-windows-cargo-fixture", + Value::Null, + ), + ]; + for (label, kind, image) in cases { + let mut value = description(); + value["kind"] = json!(kind); + value["image_id"] = image; + assert!( + CargoFixture::parse(root, &value).is_err(), + "should reject {label}" + ); + } +} + +#[test] +fn cargo_container_settings_are_explicit_and_fit_the_runner() { + let settings = cargo_docker_settings((1001, 1001), 16); + assert_eq!(settings.pids_limit, 4096, "should allow Cargo's job tree"); + assert_eq!( + settings.memory_bytes, + 8 * 1024 * 1024 * 1024, + "should give the Wasm link 8 GiB" + ); + assert_eq!(settings.cpus, 4, "should cap CPUs at a 4-vCPU runner"); + assert_eq!( + cargo_docker_settings((1001, 1001), 2).cpus, + 2, + "should never exceed host parallelism" + ); +} + +#[test] +fn build_provenance_records_digests_and_an_explicit_cache_outcome() { + let probe = b"fictional probe bytes"; + let test_binary = b"fictional test binary bytes"; + let restored = build_provenance(probe, test_binary, Some("release-tool-example-key")); + assert_eq!( + restored["producer_probe_sha256"], + hex::encode(Sha256::digest(probe)), + "should hash the exact probe bytes" + ); + assert_eq!( + restored["test_binary_sha256"], + hex::encode(Sha256::digest(test_binary)), + "should hash the exact test binary bytes" + ); + assert_eq!( + restored["build_cache"], "release-tool-example-key", + "should record the restored cache key" + ); + assert_eq!( + build_provenance(probe, test_binary, Some("miss"))["build_cache"], + "miss", + "should record a reported miss" + ); + for absent in [None, Some("")] { + assert_eq!( + build_provenance(probe, test_binary, absent)["build_cache"], + "unreported", + "should record an unreported cache rather than omit it" + ); + } +} + +#[test] +fn reported_errno_reads_only_the_named_case() { + let text = "--- stderr\n TS_DENIAL case=build-direct errno=[1]\n"; + assert_eq!( + reported_errno("build-direct", text), + Some(1), + "should read errno" + ); + assert_eq!( + reported_errno( + "build-grandchild", + "TS_DENIAL case=build-grandchild child errno=[13]" + ), + Some(13), + "should read the grandchild report" + ); + assert_eq!( + reported_errno("macro-attempt", text), + None, + "should not read another case" + ); + for other in [ + "TS_DENIAL case=build-direct connected", + "TS_DENIAL case=build-direct child failed with exit status: 1", + "TS_DENIAL case=build-direct errno=[1", + ] { + assert_eq!( + reported_errno("build-direct", other), + None, + "should not invent an errno from {other:?}" + ); + } +} + +#[test] +fn denial_fixture_rows_stay_outside_the_fixed_table() { + for package in [ + "build-direct", + "build-grandchild", + "macro-attempt", + "control", + ] { + let asset = AssetId::denial_fixture(package).expect("should resolve the fixture member"); + let artifact = asset.artifact(); + assert_eq!( + artifact.bin, package, + "should name the binary after the package" + ); + assert_eq!( + artifact.target, + ReleaseTarget::Wasm32Wasip1, + "should build the fixture for Wasm" + ); + assert!( + AssetId::parse(artifact.name).is_err(), + "should keep {} unreachable from request text", + artifact.name + ); + assert!( + FIXED_ARTIFACTS + .iter() + .all(|fixed| fixed.name != artifact.name && fixed.package != package), + "should not touch the fixed release table" + ); + } + assert!( + AssetId::denial_fixture("trusted-server-adapter-fastly").is_err(), + "should resolve only fixture members" + ); +} + +#[test] +fn darwin_denial_rows_stay_outside_the_fixed_table() { + for target in [ + ReleaseTarget::Aarch64AppleDarwin, + ReleaseTarget::X86_64AppleDarwin, + ] { + for package in [ + "build-direct", + "build-grandchild", + "macro-attempt", + "control", + ] { + let artifact = AssetId::darwin_denial_fixture(package, target) + .expect("should resolve the Darwin fixture member") + .artifact(); + assert_eq!( + artifact.bin, package, + "should name the binary after the package" + ); + assert_eq!( + artifact.target, target, + "should build for the Darwin target" + ); + assert!( + artifact.name.ends_with(target.triple()), + "should name the row after its target" + ); + assert!( + AssetId::parse(artifact.name).is_err(), + "should keep {} unreachable from request text", + artifact.name + ); + assert!( + FIXED_ARTIFACTS + .iter() + .all(|fixed| fixed.name != artifact.name && fixed.package != package), + "should not touch the fixed release table" + ); + } + } + for (package, target) in [ + ("trusted-server-cli", ReleaseTarget::Aarch64AppleDarwin), + ("control", ReleaseTarget::Wasm32Wasip1), + ("control", ReleaseTarget::X86_64UnknownLinuxGnu), + ] { + assert!( + AssetId::darwin_denial_fixture(package, target).is_err(), + "should resolve only Darwin fixture members, not {package} for {}", + target.triple() + ); + } +} + +fn description() -> Value { + let selector = "git+https://example.com/org/workspace?tag=v1"; + json!({ + "schema_version": 1, + "kind": "ordinary-ci-cargo-fixture", + "acquisition": "unauthenticated-ordinary-ci", + "source_commit": "0123456789abcdef0123456789abcdef01234567", + "cargo_lock_sha256": "a".repeat(64), + "image_id": format!("sha256:{}", "b".repeat(64)), + "rustc_version": "rustc 1.95.0 (example)", + "registry": [ + {"name": "example", "version": "1.0.0", "path": "crates/example-1.0.0.crate"} + ], + "git": [ + {"selector": selector, "revision": "c".repeat(40), "objects": "git/0/objects"} + ], + "comparison_manifests": [{ + "name": "example-git", + "version": "0.1.0", + "selector": selector, + "cargo_vendor_manifest": "cargo-vendor/example-git-0.1.0/Cargo.toml", + }], + }) +} + +/// SHA-256 of the exact original JS inventory this build consumed. +/// +/// # Panics +/// +/// Panics when the completed build retains no original JS inventory. +pub(super) fn js_inventory_sha256(completed: &RecipeCompleted) -> String { + completed + .javascript_contributions() + .expect("should retain the original JS inventory") + .inventory_sha256() + .to_owned() +} diff --git a/crates/trusted-server-release/src/recipe/tests/cli.rs b/crates/trusted-server-release/src/recipe/tests/cli.rs new file mode 100644 index 000000000..9ffa3a84b --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/cli.rs @@ -0,0 +1,473 @@ +//! Explicit real Linux CLI candidate fixture; never selected by a normal +//! focused unit run. +//! +//! The run is ordinary CI candidate evidence (`release_qualification: +//! false`): the toolchain, builder and startup images and fixture +//! acquisition are unqualified and unauthenticated, so nothing here +//! qualifies a release. The canonical build is gated (ELF policy, debug +//! info, remap scan, planted operator values, clean startup); the +//! reproduction build is a recorded measurement only, and runs only when +//! `TS_RELEASE_NATIVE_REPRODUCTION=measure` (otherwise the measurement +//! records it as deferred to the scheduled run). + +use std::ffi::OsString; +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::super::compare::read_artifact; +use super::super::native::{check_operator_values_absent, measure_native_repeatability}; +use super::super::remap::scan_remapped_bytes; +use super::super::startup::{LINUX_CLI_ASSET, NativeStartupAccepted, StartupCopy}; +use super::candidate::js_inventory_sha256; +use super::candidate::{CargoFixture, cargo_docker_settings, ci_build_provenance}; +use super::cli_candidate::{ + CliCandidateFacts, CliFixture, NativeReproduction, OPERATOR_SENTINELS, ReproductionOutcome, + check_build_budget, cli_candidate_measurement, finish_cleanup, native_reproduction_from_env, + new_startup_client, reproduction_result, +}; +use super::full::try_proven_boundary; +use super::real_fixture::{ + cargo_fixture, environment_with_operator_values, path_text, try_ordinary_ci_ambient, + try_prepare_vendor, +}; +use super::*; +use crate::boundary::{Boundary, BoundaryRoots, ConfinedCommand, ImageSelection, ProbeProgram}; +use crate::documents::{parse_json, read_bounded}; +use crate::inspect::{ElfPolicy, inspect_elf}; +use crate::preflight::AuditedEnvironment; +use crate::preparation::tests::full::{JsFixtureRun, run_two_fresh_js_builds}; +use crate::process::{Cancellation, ProcessLimits}; + +/// Reads the ordinary CI CLI fixture named by `TS_RELEASE_CLI_FIXTURE_ROOT`. +fn cli_fixture() -> (PathBuf, CliFixture) { + let root = std::env::var_os("TS_RELEASE_CLI_FIXTURE_ROOT") + .map(PathBuf::from) + .expect("should explicitly supply the ordinary CI CLI fixture root"); + let root = fs::canonicalize(root).expect("should resolve the CLI fixture root"); + let raw = read_bounded(&root.join("cli-fixture.json"), DocumentRole::Metadata) + .expect("should read the CLI fixture description"); + let document = + parse_json(&raw.bytes, DocumentRole::Metadata).expect("should parse the description"); + let fixture = CliFixture::parse(&document).expect("should accept the CLI fixture description"); + (root, fixture) +} + +/// Everything shared by both CLI builds. +struct CliInputs<'a> { + js: &'a JsFixtureRun, + fixture: &'a CargoFixture, + cli: &'a CliFixture, + audited: &'a AuditedEnvironment, + deadline: Instant, + cancellation: &'a Cancellation, +} + +/// One confined CLI build with the private roots its output lives in. +struct CliBuild { + _materials: tempfile::TempDir, + _work: tempfile::TempDir, + completed: RecipeCompleted, + /// Host roots of this build that must not appear in the final bytes. + host_roots: Vec, +} + +/// Builds the Linux `ts` for `role` from the shared JS handoff in the +/// glibc 2.34 builder image. +/// +/// Each role gets its own fresh source copy, vendor tree, state, output and +/// target roots and its own proven boundary. Setup, execution and explicit +/// source/target cleanup failures are returned so reproduction is optional. +fn build_cli(inputs: &CliInputs<'_>, role: Role) -> Result> { + check_build_budget(inputs.deadline, inputs.cancellation)?; + let fresh = inputs + .js + .source + .materialize(inputs.deadline, inputs.cancellation)?; + let mut materials_directory = None; + let mut work = None; + let mut target = None; + let built = (|| { + materials_directory = Some(tempfile::tempdir().map_err(fixture_io)?); + let materials_root = fs::canonicalize( + materials_directory + .as_ref() + .ok_or_else(|| recipe_error("missing materials root"))? + .path(), + ) + .map_err(fixture_io)?; + let materials = try_prepare_vendor( + inputs.fixture, + &inputs.js.locked, + &materials_root, + inputs.deadline, + inputs.cancellation, + )?; + work = Some(tempfile::tempdir().map_err(fixture_io)?); + let base = fs::canonicalize( + work.as_ref() + .ok_or_else(|| recipe_error("missing work root"))? + .path(), + ) + .map_err(fixture_io)?; + target = Some(base.join("target")); + for directory in ["cargo-state", "output"] { + fs::create_dir(base.join(directory)).map_err(fixture_io)?; + } + let retained = + fs::canonicalize(inputs.js.completed.rehash_for_handoff()?).map_err(fixture_io)?; + let request = RecipeRequest::parse(&RecipeInput { + asset: LINUX_CLI_ASSET, + variant: "default", + role: role.spec_label(), + commit: &inputs.fixture.source_commit, + js_dir: &retained.join("bundles"), + js_inventory: &retained.join("tsjs-inventory.json"), + materials: &materials_root, + cargo_state: &base.join("cargo-state"), + output: &base.join("output"), + target_dir: &base.join("target"), + })?; + let prepared = PreparedRecipe::new( + &inputs.js.source, + &fresh, + inputs.audited, + &inputs.js.completed, + SelectedRustTools::for_tests( + &inputs.fixture.tools(), + ReleaseTarget::X86_64UnknownLinuxGnu, + )?, + materials, + try_ordinary_ci_ambient(role, fresh.root())?, + request, + )?; + let proven = try_proven_boundary(&prepared, inputs.fixture, &inputs.cli.builder_image_id)?; + let host_roots = [ + fresh.root(), + materials_root.as_path(), + base.as_path(), + retained.as_path(), + ] + .iter() + .map(|root| { + root.to_str() + .map(str::to_owned) + .ok_or_else(|| recipe_error("CLI root is not UTF-8")) + }) + .collect::, _>>()?; + let completed = prepared.execute(&proven.boundary, &proven.proof)?; + completed.rehash_for_handoff()?; + Ok((completed, host_roots)) + })(); + // Attempt every cleanup even when setup, execution, or another cleanup fails. + let built = finish_cleanup(built, fresh.close()); + let built = finish_cleanup( + built, + target + .as_ref() + .map_or(Ok(()), |target| match fs::remove_dir_all(target) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(fixture_io(error)), + }), + ); + let built = built.and_then(|(completed, host_roots)| { + completed.denial_receipt()?; + completed.recipe_definition()?; + Ok((completed, host_roots)) + }); + match built { + Ok((completed, host_roots)) => Ok(CliBuild { + _materials: materials_directory + .ok_or_else(|| recipe_error("missing materials root"))?, + _work: work.ok_or_else(|| recipe_error("missing work root"))?, + completed, + host_roots, + }), + Err(error) => { + let result = finish_cleanup::<()>(Err(error), close_directory(materials_directory)); + Err(finish_cleanup(result, close_directory(work)) + .expect_err("should retain the failed build and cleanup errors")) + } + } +} + +fn fixture_io(error: std::io::Error) -> Report { + Report::new(ReleaseError::Recipe) + .attach("CLI fixture filesystem operation failed") + .attach(error) +} + +fn close_directory(directory: Option) -> Result<(), Report> { + directory.map_or(Ok(()), |directory| directory.close().map_err(fixture_io)) +} + +/// Scans the final bytes for every host and fixture root and the container +/// prefix, and returns the fixed remapped prefixes found. +fn scan_final_bytes(bytes: &[u8], build: &CliBuild, fixture_roots: &[&Path]) -> Vec<&'static str> { + let mut forbidden = build.host_roots.clone(); + forbidden.extend(fixture_roots.iter().map(|root| path_text(root))); + if let Some(runner_temp) = std::env::var_os("RUNNER_TEMP") { + forbidden.push( + runner_temp + .into_string() + .expect("should name RUNNER_TEMP in UTF-8"), + ); + } + let forbidden: Vec<&str> = forbidden.iter().map(String::as_str).collect(); + scan_remapped_bytes(bytes, &forbidden) + .unwrap_or_else(|error| panic!("should prove remapping in the final bytes: {error:?}")) + .found() + .to_vec() +} + +/// Starts the exact final bytes as `ts --version` in every clean image. +fn start_everywhere(completed: &RecipeCompleted, cli: &CliFixture) -> NativeStartupAccepted { + let scratch = tempfile::tempdir().expect("should create startup scratch space"); + let base = fs::canonicalize(scratch.path()).expect("should resolve startup scratch space"); + let copy_directory = base.join("startup-copy"); + let copy = StartupCopy::create(completed, ©_directory) + .expect("should copy the final bytes read-only"); + let observed = (|| { + let (_state, client) = new_startup_client(Path::new("/usr/bin/docker"), ©_directory)?; + NativeStartupAccepted::accept_linux(completed, copy, &client, &cli.startup_images) + })(); + let observed = observed.expect("should start every clean image and remove the startup copy"); + completed + .rehash_for_handoff() + .expect("should leave the accepted artifact unchanged"); + observed +} + +#[test] +#[ignore = "requires explicit JS, Cargo and CLI ordinary CI fixtures and Linux Docker; run separately in producer CI"] +fn linux_cli_candidate_builds_inspects_and_starts_cleanly() { + // Read before any build so a missing selection fails in seconds. + let native_reproduction = native_reproduction_from_env(); + let fixture = cargo_fixture(); + let provenance = ci_build_provenance(&fixture); + let (cli_root, cli) = cli_fixture(); + preflight_cli_builder(&fixture, &cli, &cli_root) + .expect("should load the staged probe and Rust tools on glibc 2.34 before JS builds"); + let js = run_two_fresh_js_builds(); + assert_eq!( + js.source.commit().as_str(), + fixture.source_commit, + "should build the Cargo fixture's source commit" + ); + let (audited, removed) = environment_with_operator_values(); + let cancellation = Cancellation::default(); + let inputs = CliInputs { + js: &js, + fixture: &fixture, + cli: &cli, + audited: &audited, + deadline: Instant::now() + Duration::from_secs(50 * 60), + cancellation: &cancellation, + }; + let canonical = build_cli(&inputs, Role::Canonical).unwrap_or_else(|error| { + panic!("should build the canonical Linux ts through the confined recipe: {error:?}") + }); + let bytes = read_artifact(&canonical.completed).expect("should reread the final bytes"); + let elf = inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()) + .expect("should accept the final bytes under the glibc 2.34 ELF policy"); + elf.require_debug_sections() + .expect("should keep the profile's debug info"); + let remap_found = scan_final_bytes(&bytes, &canonical, &[&fixture.root, &cli_root]); + let planted: Vec<&str> = OPERATOR_SENTINELS.iter().map(|(_, value)| *value).collect(); + check_operator_values_absent(&bytes, &planted) + .expect("should embed none of the planted operator values"); + let startup = start_everywhere(&canonical.completed, &cli); + let mut canonical_measurement = cli_candidate_measurement(&CliCandidateFacts { + canonical: &canonical.completed, + builder: &cli, + elf: &elf, + remap_found: &remap_found, + operator_values_removed: &removed, + startup: startup + .linux_runs() + .expect("should retain actual Linux startup runs"), + reproduction: ReproductionOutcome::NotAttempted, + }); + let inventory_sha256 = js_inventory_sha256(&canonical.completed); + canonical_measurement["js_inventory_sha256"] = serde_json::json!(inventory_sha256); + canonical_measurement["ci_build_provenance"] = provenance.clone(); + fs::write( + cli_root.join("linux-cli-candidate-measurement.json"), + serde_json::to_vec_pretty(&canonical_measurement) + .expect("should encode the canonical measurement"), + ) + .expect("should retain accepted canonical evidence before optional reproduction"); + let reproduction_build = match native_reproduction { + NativeReproduction::Measure => Some(build_cli(&inputs, Role::Reproduction)), + NativeReproduction::Defer => None, + }; + + let paired = reproduction_build.as_ref().map(|build| { + reproduction_result( + build + .as_ref() + .map_err(|error| recipe_error(format!("{error:?}"))), + |reproduction| { + measure_native_repeatability(&canonical.completed, &reproduction.completed) + }, + ) + }); + let reproduction = match &paired { + None => ReproductionOutcome::Deferred, + Some((Ok(measured), _)) => ReproductionOutcome::Measured(measured), + Some((Err(error), Some(_))) => ReproductionOutcome::InvariantFailed(error.clone()), + Some((Err(error), None)) => ReproductionOutcome::BuildFailed(error.clone()), + }; + let mut measurement = cli_candidate_measurement(&CliCandidateFacts { + canonical: &canonical.completed, + builder: &cli, + elf: &elf, + remap_found: &remap_found, + operator_values_removed: &removed, + startup: startup + .linux_runs() + .expect("should retain actual Linux startup runs"), + reproduction, + }); + measurement["js_inventory_sha256"] = serde_json::json!(inventory_sha256); + measurement["ci_build_provenance"] = provenance; + retain_cli_measurement(&cli_root, &measurement); + if let Some((_, Some(error))) = &paired { + panic!("should pair builds over identical inputs: {error:?}"); + } +} + +/// Retains `measurement` as the Linux CLI candidate measurement. +fn retain_cli_measurement(cli_root: &Path, measurement: &serde_json::Value) { + let text = serde_json::to_string_pretty(measurement).expect("should encode the measurement"); + fs::write(cli_root.join("linux-cli-candidate-measurement.json"), text) + .expect("should retain the Linux CLI candidate measurement"); +} + +/// Loads the staged probe and copied Rust tools on the builder's ABI before JS work. +fn preflight_cli_builder( + fixture: &CargoFixture, + cli: &CliFixture, + evidence_root: &Path, +) -> Result<(), Report> { + let work = tempfile::tempdir().map_err(fixture_io)?; + let state = tempfile::tempdir().map_err(fixture_io)?; + let scripts = tempfile::tempdir().map_err(fixture_io)?; + let root = fs::canonicalize(work.path()).map_err(fixture_io)?; + let result = (|| { + let script_root = fs::canonicalize(scripts.path()).map_err(fixture_io)?; + let script = script_root.join("filesystem-controls.sh"); + fs::write(&script, FILESYSTEM_CONTROLS).map_err(fixture_io)?; + fs::set_permissions(&script, fs::Permissions::from_mode(0o555)).map_err(fixture_io)?; + for directory in ["home", "tmp", "cargo-home"] { + fs::create_dir(root.join(directory)).map_err(fixture_io)?; + } + let roots = BoundaryRoots::new( + &[script_root], + &[fixture.tools()], + std::slice::from_ref(&root), + )?; + + // These IDs select the existing unprivileged ordinary CI worker. + let user = unsafe { (libc::getuid(), libc::getgid()) }; + let boundary = Boundary::linux_container( + roots, + cargo_docker_settings(user, 1), + ImageSelection::Test { + id: cli.builder_image_id.clone(), + }, + state.path(), + )?; + let limits = ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 4096, + stderr_max: 4096, + }; + let probe = ProbeProgram::from_staged_release_binary(&fixture.probe())?; + let proof = boundary.establish_denial(&probe, limits)?; + let controls = ConfinedCommand::new(&script, &root, limits)? + .environment("HOME", boundary.execution_path(&root.join("home"))?)? + .environment("TMPDIR", boundary.execution_path(&root.join("tmp"))?)? + .environment( + "CARGO_HOME", + boundary.execution_path(&root.join("cargo-home"))?, + )? + .environment("LC_ALL", "C")?; + let observed = boundary.run_confined(&controls, &proof)?; + let evidence = serde_json::json!({ + "kind": "ordinary-ci-builder-filesystem-controls", + "builder_image_id": cli.builder_image_id, + "stdout": String::from_utf8_lossy(observed.stdout()), + "stderr": String::from_utf8_lossy(observed.stderr()), + "outcome": format!("{:?}", observed.outcome()), + "release_qualification": false, + }); + let evidence = serde_json::to_vec_pretty(&evidence) + .map_err(|error| recipe_error(format!("cannot encode filesystem controls: {error}")))?; + fs::write( + evidence_root.join("builder-filesystem-controls.json"), + evidence, + ) + .map_err(fixture_io)?; + + let stdout = observed.require_success()?; + if stdout != FILESYSTEM_CONTROL_OUTPUT.as_bytes() { + return Err(recipe_error( + "filesystem controls did not report every denied root and bound TMPDIR success", + )); + } + if root + .join("tmp/ts-release-filesystem-control") + .try_exists() + .map_err(fixture_io)? + { + return Err(recipe_error( + "filesystem controls left the bound temporary file behind", + )); + } + for tool in ["rustc", "cargo"] { + let command = + ConfinedCommand::new(&fixture.tools().join("bin").join(tool), &root, limits)? + .arguments([OsString::from("--version")]) + .environment("LC_ALL", "C")?; + boundary.run_confined(&command, &proof)?.require_success()?; + } + Ok(()) + })(); + let result = finish_cleanup(result, scripts.close().map_err(fixture_io)); + let result = finish_cleanup(result, state.close().map_err(fixture_io)); + finish_cleanup(result, work.close().map_err(fixture_io)) +} + +/// Fixed ordinary CI controls: redirections test permissions without external helpers. +const FILESYSTEM_CONTROLS: &str = r#"#!/bin/sh +set -eu +for directory in /tmp /dev/shm /dev; do + if ( : > "$directory/ts-release-filesystem-control" ); then + printf 'unexpected writable root %s\n' "$directory" >&2 + exit 1 + fi + printf 'denied %s\n' "$directory" +done +for file in /etc/hosts /etc/hostname /etc/resolv.conf; do + [ -f "$file" ] + if ( : >> "$file" ); then + printf 'unexpected writable daemon file %s\n' "$file" >&2 + exit 1 + fi + printf 'denied %s\n' "$file" +done +path=$TMPDIR/ts-release-filesystem-control + +printf 'bound temporary root\n' > "$path" +IFS= read -r observed < "$path" +[ "$observed" = 'bound temporary root' ] +/usr/bin/rm -- "$path" +[ ! -e "$path" ] +printf 'bound TMPDIR write read cleanup accepted\n' +"#; + +/// Every expected control must report success, in the fixed script's order. +const FILESYSTEM_CONTROL_OUTPUT: &str = "denied /tmp\ndenied /dev/shm\ndenied /dev\ndenied /etc/hosts\ndenied /etc/hostname\ndenied /etc/resolv.conf\nbound TMPDIR write read cleanup accepted\n"; diff --git a/crates/trusted-server-release/src/recipe/tests/cli_candidate.rs b/crates/trusted-server-release/src/recipe/tests/cli_candidate.rs new file mode 100644 index 000000000..6e0f1954c --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/cli_candidate.rs @@ -0,0 +1,738 @@ +//! Ordinary CI Linux CLI candidate support: the CLI fixture description and +//! the measurement the real Linux fixture prints. +//! +//! Nothing here is release qualification. The images are acquired without +//! authentication and selected only by local image ID, the toolchain is +//! unqualified, and the measurement says so. + +use std::collections::BTreeSet; +use std::path::Path; +use std::time::Instant; + +use crate::process::Cancellation; + +use serde_json::{Value, json}; + +use super::super::native::NativeRepeatability; +use super::super::startup::{ + StartupClient, StartupDistribution, StartupImage, StartupObserved, is_local_image_id, +}; +use super::candidate::{SCHEMA_VERSION, cargo_measurement}; +use super::complete::built; +use super::*; +use crate::inspect::ElfFacts; +use crate::inspect::tests::builders::{ElfSpec, elf}; +use crate::inspect::{ElfPolicy, inspect_elf}; + +/// Kind of the CLI fixture description. +const FIXTURE_KIND: &str = "ordinary-ci-cli-fixture"; +/// How the fixture images were acquired. +const ACQUISITION: &str = "unauthenticated-ordinary-ci"; +/// Every field `cli-fixture.json` must hold, and nothing else. +const FIXTURE_FIELDS: [&str; 6] = [ + "acquisition", + "builder_glibc", + "builder_image_id", + "kind", + "schema_version", + "startup_images", +]; +/// Base image of the ordinary CI builder; the tracked Dockerfile must use it. +pub(super) const CLI_BUILDER_BASE: &str = "registry.access.redhat.com/ubi9/ubi:9.6"; +/// The tracked ordinary CI builder Dockerfile. +const CLI_BUILDER_DOCKERFILE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../scripts/release/tests/cli-builder.Dockerfile" +)); +/// Distinctive fictional operator values planted in the CLI build's +/// inherited environment; preflight must remove them and the final bytes +/// must hold none. +pub(super) const OPERATOR_SENTINELS: [(&str, &str); 3] = [ + ( + "TRUSTED_SERVER__PUBLISHER__DOMAIN", + "ts-operator-sentinel-publisher.example.com", + ), + ( + "TRUSTED_SERVER__PUBLISHER__ORIGIN_URL", + "https://ts-operator-sentinel-origin.example.com", + ), + ( + "EDGEZERO__STORES__CONFIG__NAME", + "ts-operator-sentinel-config-store-0001", + ), +]; + +fn fixture_error(detail: impl std::fmt::Display) -> Report { + recipe_error(format!("cli fixture: {detail}")) +} + +/// The parsed, unauthenticated ordinary CI CLI fixture. +#[derive(Debug)] +pub(super) struct CliFixture { + pub(super) builder_image_id: String, + pub(super) builder_glibc: String, + pub(super) startup_images: Vec, +} + +impl CliFixture { + /// Parses `cli-fixture.json`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for another schema, kind or + /// acquisition, a missing or extra field, a builder that is not a local + /// image ID or does not report glibc 2.34, or startup images that are not + /// exactly the reviewed distributions, in order, at their reviewed + /// references and selected by local image ID. + pub(super) fn parse(document: &Value) -> Result> { + let fields: BTreeSet<&str> = document + .as_object() + .ok_or_else(|| fixture_error("description is not an object"))? + .keys() + .map(String::as_str) + .collect(); + if fields != BTreeSet::from(FIXTURE_FIELDS) { + return Err(fixture_error("description fields differ from the schema")); + } + if document["schema_version"].as_u64() != Some(SCHEMA_VERSION) + || document["kind"].as_str() != Some(FIXTURE_KIND) + || document["acquisition"].as_str() != Some(ACQUISITION) + { + return Err(fixture_error("unsupported schema, kind or acquisition")); + } + let text = |name: &str| { + document[name] + .as_str() + .ok_or_else(|| fixture_error(format!("{name} is not a string"))) + }; + let builder_image_id = text("builder_image_id")?.to_owned(); + if !is_local_image_id(&builder_image_id) { + return Err(fixture_error("builder is not a local image ID")); + } + let builder_glibc = text("builder_glibc")?.to_owned(); + if !builder_glibc.ends_with(" 2.34") { + return Err(fixture_error("builder does not report glibc 2.34")); + } + let entries = document["startup_images"] + .as_array() + .ok_or_else(|| fixture_error("startup_images is not an array"))?; + if entries.len() != StartupDistribution::ALL.len() { + return Err(fixture_error("startup images differ from the reviewed set")); + } + let startup_images = entries + .iter() + .zip(StartupDistribution::ALL) + .map(|(entry, distribution)| startup_image(entry, distribution)) + .collect::>()?; + Ok(Self { + builder_image_id, + builder_glibc, + startup_images, + }) + } +} + +/// Parses one startup entry, which must be `distribution` at its reference. +fn startup_image( + entry: &Value, + distribution: StartupDistribution, +) -> Result> { + let fields: Option> = entry + .as_object() + .map(|object| object.keys().map(String::as_str).collect()); + if fields != Some(BTreeSet::from(["distribution", "image_id", "reference"])) { + return Err(fixture_error("startup entry fields differ from the schema")); + } + if entry["distribution"].as_str() != Some(distribution.label()) + || entry["reference"].as_str() != Some(distribution.reference()) + { + return Err(fixture_error(format!( + "startup entry is not {} at {}", + distribution.label(), + distribution.reference() + ))); + } + let id = entry["image_id"] + .as_str() + .ok_or_else(|| fixture_error("startup image_id is not a string"))?; + StartupImage::new(distribution, id) +} + +/// The ELF facts the measurement records. +fn elf_measurement(facts: &ElfFacts) -> Value { + let debug: Vec = facts + .debug_sections() + .iter() + .map(|section| json!({"name": section.name(), "size": section.size()})) + .collect(); + json!({ + "sha256": facts.sha256(), + "interpreter": facts.interpreter(), + "needed": facts.needed(), + "max_glibc": facts.max_glibc().to_string(), + "version_requirements": facts.version_requirements().len(), + "debug_sections": debug, + }) +} + +/// The startup runs the measurement records. +fn startup_measurement(runs: &[StartupObserved]) -> Value { + runs.iter() + .map(|run| { + let distribution = run.image.distribution(); + json!({ + "distribution": distribution.label(), + "reference": distribution.reference(), + "image_id": run.image.id(), + "exit_code": 0, + "stdout": run.stdout, + "stderr_sha256": run.stderr_sha256, + "elapsed_seconds": run.elapsed.as_secs_f64(), + }) + }) + .collect() +} + +/// Selects whether a native CLI candidate job measures its reproduction build. +/// +/// The ordinary CI workflow always sets it: `measure` on scheduled and +/// dispatched runs (and on a labelled pull request), `defer` otherwise. +pub(super) const NATIVE_REPRODUCTION_VARIABLE: &str = "TS_RELEASE_NATIVE_REPRODUCTION"; +/// The GitHub Actions variable naming the event that started the run. +const GITHUB_EVENT_VARIABLE: &str = "GITHUB_EVENT_NAME"; +/// Events whose run exists to measure native reproduction, so may not defer it. +const MEASURING_EVENTS: [&str; 2] = ["schedule", "workflow_dispatch"]; + +/// Whether a native CLI candidate job builds and measures its reproduction. +/// +/// The reproduction is a measurement, never a gate, for the native CLI +/// candidates only; the Fastly equality step never reads this selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum NativeReproduction { + /// Build the reproduction and record how it compares. + Measure, + /// Skip the reproduction build and record that a scheduled run measures it. + Defer, +} + +/// Parses the native reproduction selection and the GitHub event name. +/// +/// # Errors +/// +/// Returns a message for a missing or unknown selection (only the exact +/// lowercase `measure` and `defer` are accepted), or for `defer` on a +/// scheduled or dispatched run, which exists to measure the reproduction. +pub(super) fn parse_native_reproduction( + value: Option<&str>, + event: Option<&str>, +) -> Result { + let selected = match value { + Some("measure") => NativeReproduction::Measure, + Some("defer") => NativeReproduction::Defer, + Some(other) => { + return Err(format!( + "{NATIVE_REPRODUCTION_VARIABLE} must be `measure` or `defer`, not {other:?}" + )); + } + None => { + return Err(format!( + "{NATIVE_REPRODUCTION_VARIABLE} must be set to `measure` or `defer`" + )); + } + }; + if selected == NativeReproduction::Defer + && let Some(event) = event.filter(|event| MEASURING_EVENTS.contains(event)) + { + return Err(format!( + "{NATIVE_REPRODUCTION_VARIABLE}=defer is not allowed on a {event} run, which exists to measure native reproduction" + )); + } + Ok(selected) +} + +/// Reads the native reproduction selection from the test environment. +/// +/// # Panics +/// +/// Panics when [`parse_native_reproduction`] rejects the selection or either +/// variable is not UTF-8, so a misconfigured job fails before any build. +pub(super) fn native_reproduction_from_env() -> NativeReproduction { + let read = |name: &str| { + std::env::var_os(name).map(|value| { + value + .into_string() + .unwrap_or_else(|_| panic!("should name {name} in UTF-8")) + }) + }; + let value = read(NATIVE_REPRODUCTION_VARIABLE); + let event = read(GITHUB_EVENT_VARIABLE); + parse_native_reproduction(value.as_deref(), event.as_deref()).unwrap_or_else(|error| { + panic!("should select the native reproduction mode explicitly: {error}") + }) +} + +/// What the optional reproduction build produced. +pub(super) enum ReproductionOutcome<'a> { + /// Canonical evidence is retained before the optional build starts. + NotAttempted, + /// The job selected `defer`; a scheduled or dispatched run measures it. + Deferred, + /// A completed pair violated its input invariants. + InvariantFailed(String), + /// Both builds completed and were compared. + Measured(&'a NativeRepeatability), + /// The reproduction build itself failed; recorded, not a gate. + BuildFailed(String), +} + +/// The reproduction outcome the measurement records. +pub(super) fn reproduction_measurement(outcome: &ReproductionOutcome<'_>) -> Value { + match outcome { + ReproductionOutcome::NotAttempted => { + json!({"status": "not-attempted", "gate": "measurement-only"}) + } + ReproductionOutcome::Deferred => json!({ + "status": "deferred-to-scheduled-run", + "gate": "measurement-only", + "selector": format!("{NATIVE_REPRODUCTION_VARIABLE}=defer"), + "measured_by": "scheduled/dispatched Run Tests on main", + }), + ReproductionOutcome::InvariantFailed(error) => { + json!({"status": "invariant-failed", "error": error, "gate": "fatal-input-invariant"}) + } + + ReproductionOutcome::Measured(measured) => { + let difference = measured.difference().map(|difference| { + json!({ + "first_offset": difference.first_offset, + "differing_bytes": difference.differing_bytes, + "canonical_section": difference.canonical_section, + }) + }); + json!({ + "status": if measured.identical() { "identical" } else { "differing" }, + "canonical": { + "sha256": measured.canonical().sha256, + "size": measured.canonical().size, + }, + "reproduction": { + "sha256": measured.reproduction().sha256, + "size": measured.reproduction().size, + }, + "difference": difference, + "gate": "measurement-only", + }) + } + ReproductionOutcome::BuildFailed(error) => json!({ + "status": "build-failed", + "error": error, + "gate": "measurement-only", + }), + } +} + +/// Everything the Linux CLI candidate measurement states. +pub(super) struct CliCandidateFacts<'a> { + pub(super) canonical: &'a RecipeCompleted, + pub(super) builder: &'a CliFixture, + pub(super) elf: &'a ElfFacts, + pub(super) remap_found: &'a [&'static str], + pub(super) operator_values_removed: &'a [String], + pub(super) startup: &'a [StartupObserved], + pub(super) reproduction: ReproductionOutcome<'a>, +} + +/// The ordinary CI measurement of the Linux CLI candidate. +pub(super) fn cli_candidate_measurement(facts: &CliCandidateFacts<'_>) -> Value { + json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-linux-cli-candidate", + "asset": facts.canonical.asset().artifact().name, + "source_commit": facts.canonical.commit().as_str(), + "builder": { + "base": CLI_BUILDER_BASE, + "image_id": facts.builder.builder_image_id, + "glibc": facts.builder.builder_glibc, + }, + "canonical": cargo_measurement(facts.canonical), + "elf": elf_measurement(facts.elf), + "remap_scan": { + "forbidden": ["/boundary/", "host build roots", "fixture roots", "RUNNER_TEMP"], + "found": facts.remap_found, + }, + "operator_values": { + "removed_by_preflight": facts.operator_values_removed, + "found_in_final_bytes": 0, + }, + "startup": startup_measurement(facts.startup), + "reproduction": reproduction_measurement(&facts.reproduction), + "release_qualification": false, + }) +} + +/// Returns the image named by the `FROM` line of `dockerfile`. +fn dockerfile_base(dockerfile: &str) -> Option<&str> { + let mut bases = dockerfile + .lines() + .filter_map(|line| line.strip_prefix("FROM ")); + match (bases.next(), bases.next()) { + (Some(base), None) => Some(base.trim()), + _ => None, + } +} + +#[test] +fn builder_dockerfile_uses_the_reviewed_glibc_floor_base() { + assert_eq!( + dockerfile_base(CLI_BUILDER_DOCKERFILE), + Some(CLI_BUILDER_BASE), + "should build from exactly the reviewed tag-pinned UBI 9 base" + ); +} + +#[test] +fn cli_fixture_description_parses_the_reviewed_images() { + let parsed = CliFixture::parse(&description()).expect("should parse the description"); + assert_eq!(parsed.builder_image_id, image('c')); + let labels: Vec<&str> = parsed + .startup_images + .iter() + .map(|image| image.distribution().label()) + .collect(); + assert_eq!(labels, ["ubi9", "ubuntu-22.04", "debian-12"]); +} + +/// One in-place change to a CLI fixture description. +type Edit = fn(&mut Value); + +#[test] +fn cli_fixture_description_rejects_drift_or_unpinned_images() { + let cases: [(&str, Edit); 8] = [ + ("extra field", |value| value["extra"] = json!(true)), + ("kind", |value| { + value["kind"] = json!("release-qualification") + }), + ("builder tag", |value| { + value["builder_image_id"] = json!("example/builder:latest"); + }), + ("builder glibc", |value| { + value["builder_glibc"] = json!("ldd (GNU libc) 2.39"); + }), + ("startup tag", |value| { + value["startup_images"][1]["image_id"] = json!("ubuntu:22.04"); + }), + ("reference drift", |value| { + value["startup_images"][2]["reference"] = json!("docker.io/library/debian:13"); + }), + ("order", |value| { + value["startup_images"] + .as_array_mut() + .expect("should hold startup images") + .swap(0, 1); + }), + ("missing distribution", |value| { + value["startup_images"] + .as_array_mut() + .expect("should hold startup images") + .pop(); + }), + ]; + for (label, edit) in cases { + let mut value = description(); + edit(&mut value); + assert!( + CliFixture::parse(&value).is_err(), + "should reject a description with a bad {label}" + ); + } +} + +#[test] +fn operator_sentinels_are_removed_by_preflight_and_distinctive() { + let mut snapshot = vec![( + std::ffi::OsString::from("PATH"), + std::ffi::OsString::from("/usr/bin:/bin"), + )]; + snapshot.extend( + OPERATOR_SENTINELS + .iter() + .map(|(name, value)| ((*name).into(), (*value).into())), + ); + let audited = + crate::preflight::audit_environment(&snapshot).expect("should remove, not reject"); + let removed: BTreeSet<&str> = audited.removed_names().iter().map(String::as_str).collect(); + let planted: BTreeSet<&str> = OPERATOR_SENTINELS.iter().map(|(name, _)| *name).collect(); + assert_eq!( + removed, planted, + "should remove every planted operator name" + ); + for (_, value) in OPERATOR_SENTINELS { + assert!( + value.contains("example.com") || value.starts_with("ts-operator-sentinel-"), + "should plant only fictional values" + ); + } +} + +fn image(digit: char) -> String { + format!("sha256:{}", digit.to_string().repeat(64)) +} + +fn description() -> Value { + let startup: Vec = StartupDistribution::ALL + .iter() + .zip(['d', 'e', 'f']) + .map(|(distribution, digit)| { + json!({ + "distribution": distribution.label(), + "reference": distribution.reference(), + "image_id": image(digit), + }) + }) + .collect(); + json!({ + "schema_version": 1, + "kind": "ordinary-ci-cli-fixture", + "acquisition": "unauthenticated-ordinary-ci", + "builder_image_id": image('c'), + "builder_glibc": "ldd (GNU libc) 2.34", + "startup_images": startup, + }) +} + +#[test] +fn optional_reproduction_setup_failure_preserves_canonical_evidence() { + let bytes = elf(&ElfSpec { + payload: b"/trusted-server/src/main.rs", + debug_sections: vec![ + ( + ".debug_info", + object::elf::SHT_PROGBITS, + b"fictional dwarf info", + ), + ( + ".debug_line", + object::elf::SHT_PROGBITS, + b"fictional dwarf lines", + ), + ], + ..ElfSpec::conforming() + }); + let mut build = built( + super::super::startup::LINUX_CLI_ASSET, + Role::Canonical, + &bytes, + ); + let stdout = build.stdout(|_| {}); + let canonical = build + .finish(stdout) + .expect("should complete the canonical fixture"); + + let (measured, invariant) = reproduction_result::<(), ()>( + Err(recipe_error("reproduction materials unavailable")), + |_| panic!("should never pair a failed setup"), + ); + assert!(invariant.is_none(), "should keep setup failure optional"); + assert!( + measured + .expect_err("should record unavailable reproduction") + .contains("materials unavailable") + ); + let fixture = CliFixture::parse(&description()).expect("should parse the CLI fixture"); + let elf = inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()) + .expect("should inspect canonical bytes"); + let measurement = cli_candidate_measurement(&CliCandidateFacts { + canonical: &canonical, + builder: &fixture, + elf: &elf, + remap_found: &[], + operator_values_removed: &[], + startup: &[], + reproduction: ReproductionOutcome::BuildFailed("materials unavailable".to_owned()), + }); + assert_eq!( + measurement["canonical"]["artifact"]["sha256"], + canonical.artifact().sha256(), + "should retain the accepted canonical artifact when reproduction setup fails" + ); + assert_eq!( + measurement["reproduction"]["status"], "build-failed", + "should record unavailable reproduction" + ); + canonical + .rehash_for_handoff() + .expect("should preserve the accepted canonical bytes"); +} + +#[test] +fn native_reproduction_selection_accepts_only_exact_modes() { + for (value, event, expected) in [ + ("measure", None, NativeReproduction::Measure), + ("defer", None, NativeReproduction::Defer), + ("defer", Some("push"), NativeReproduction::Defer), + ("defer", Some("pull_request"), NativeReproduction::Defer), + ("measure", Some("schedule"), NativeReproduction::Measure), + ( + "measure", + Some("workflow_dispatch"), + NativeReproduction::Measure, + ), + ] { + assert_eq!( + parse_native_reproduction(Some(value), event), + Ok(expected), + "should accept {value:?} on event {event:?}" + ); + } + for value in [ + None, + Some(""), + Some("MEASURE"), + Some("skip"), + Some(" defer"), + ] { + let error = parse_native_reproduction(value, Some("push")) + .expect_err("should reject a missing or unknown mode"); + assert!( + error.contains(NATIVE_REPRODUCTION_VARIABLE), + "should name the selection variable: {error}" + ); + } +} + +#[test] +fn native_reproduction_cannot_be_deferred_by_a_measuring_run() { + for event in MEASURING_EVENTS { + let error = parse_native_reproduction(Some("defer"), Some(event)) + .expect_err("should refuse to defer on a scheduled or dispatched run"); + assert!( + error.contains(event), + "should name the measuring event: {error}" + ); + } +} + +#[test] +fn deferred_reproduction_is_recorded_explicitly() { + let recorded = reproduction_measurement(&ReproductionOutcome::Deferred); + assert_eq!( + recorded["status"], "deferred-to-scheduled-run", + "should state the deferral rather than omit the reproduction" + ); + assert_eq!( + recorded["gate"], "measurement-only", + "should keep the reproduction a measurement" + ); + assert_eq!( + recorded["selector"], "TS_RELEASE_NATIVE_REPRODUCTION=defer", + "should name the selection that deferred it" + ); +} + +#[test] +fn optional_reproduction_pairing_failure_remains_fatal() { + let (measured, invariant) = reproduction_result(Ok(()), |_| { + Err::<(), _>(recipe_error("different source commits")) + }); + assert!(measured.is_err(), "should record the failed pairing"); + assert!(invariant.is_some(), "should retain the fatal pairing error"); +} + +#[test] +fn reproduction_deadline_expires_before_setup() { + let cancellation = Cancellation::default(); + assert!( + check_build_budget(Instant::now(), &cancellation).is_err(), + "should return expiration without starting setup" + ); +} + +#[test] +fn cleanup_failure_preserves_both_errors() { + let result = finish_cleanup::<()>( + Err(recipe_error("setup failed")), + Err(recipe_error("source cleanup failed")), + ); + let error = format!("{:?}", result.expect_err("should report cleanup failure")); + assert!( + error.contains("setup failed") && error.contains("source cleanup failed"), + "should report setup and cleanup errors: {error}" + ); +} + +/// Separates optional build availability from fatal comparison invariants. +pub(super) fn reproduction_result( + build: Result>, + measure: impl FnOnce(T) -> Result>, +) -> (Result, Option>) { + match build { + Err(error) => (Err(format!("{error:?}")), None), + Ok(build) => match measure(build) { + Ok(measured) => (Ok(measured), None), + Err(error) => ( + Err(format!("pairing invariant failed: {error:?}")), + Some(error), + ), + }, + } +} + +/// Refuses unavailable time or cancellation before optional setup starts. +pub(super) fn check_build_budget( + deadline: Instant, + cancellation: &Cancellation, +) -> Result<(), Report> { + if Instant::now() >= deadline || cancellation.is_cancelled() { + return Err(recipe_error( + "CLI build deadline expired or was cancelled before setup", + )); + } + Ok(()) +} + +/// Retains a failed operation and every explicit cleanup failure together. +pub(super) fn finish_cleanup( + operation: Result>, + cleanup: Result<(), Report>, +) -> Result> { + match (operation, cleanup) { + (result, Ok(())) => result, + (Ok(_), Err(cleanup)) => Err(cleanup), + (Err(error), Err(cleanup)) => Err(error.attach(format!("cleanup failed: {cleanup:?}"))), + } +} + +/// Pins startup host inputs in a separate private root outside the mounted copy. +pub(super) fn new_startup_client( + docker: &Path, + copy_directory: &Path, +) -> Result<(tempfile::TempDir, StartupClient), Report> { + let state = tempfile::tempdir() + .map_err(|error| recipe_error(format!("cannot prepare startup host state: {error}")))?; + let parent = fs::canonicalize(state.path()) + .map_err(|error| recipe_error(format!("cannot prepare startup host state: {error}")))?; + let client = StartupClient::new(docker, &parent, &[copy_directory])?; + Ok((state, client)) +} + +#[test] +fn startup_host_state_is_disjoint_from_the_mounted_copy() { + let scratch = tempfile::tempdir().expect("should create startup scratch space"); + let base = fs::canonicalize(scratch.path()).expect("should resolve scratch space"); + let copy_directory = base.join("startup-copy"); + fs::create_dir(©_directory).expect("should create a fictional mount"); + let docker = base.join("docker"); + fs::write(&docker, b"fictional Docker client bytes") + .expect("should write a client without executing it"); + assert!( + StartupClient::new(&docker, &base, &[©_directory]).is_err(), + "should reject host state above the mounted copy" + ); + let (state, _client) = new_startup_client(&docker, ©_directory) + .expect("should configure the same disjoint host state used by startup"); + let state = fs::canonicalize(state.path()).expect("should resolve startup host state"); + assert!( + !state.starts_with(©_directory) && !copy_directory.starts_with(&state), + "should keep host state and mount pairwise disjoint" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/compare.rs b/crates/trusted-server-release/src/recipe/tests/compare.rs new file mode 100644 index 000000000..4d47cfc60 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/compare.rs @@ -0,0 +1,340 @@ +use sha2::Sha256; + +use super::super::ambient::AmbientClaim; +use super::super::compare::{ + FASTLY_ASSET, check_fastly_equality, describe_mismatch, first_difference, +}; +use super::super::completed::SuccessFacts; +use super::complete::{Built, ManifestMarks, built, built_with, wasm_with}; +use super::complete_darwin::darwin_executable; +use super::fixture::OTHER_COMMIT; +use super::*; + +/// A minimal Wasm module that keeps the debug sections a build must retain. +fn module(info: &[u8]) -> Vec { + wasm_with(&[(".debug_info", info), (".debug_line", b"line")]) +} + +/// A completed fictional build with the fixture roots its output lives in. +struct Completed { + _built: Built, + completed: RecipeCompleted, +} + +impl std::ops::Deref for Completed { + type Target = RecipeCompleted; + + fn deref(&self) -> &RecipeCompleted { + &self.completed + } +} + +/// Completes a fictional build of `asset` for `role` with exactly `bytes`. +fn completed(asset: &'static str, role: Role, bytes: &[u8], marks: ManifestMarks) -> Completed { + finish(built_with(asset, role, bytes, marks)) +} + +fn finish(mut built: Built) -> Completed { + let stdout = built.stdout(|_| {}); + let completed = built.finish(stdout).expect("should complete the build"); + Completed { + _built: built, + completed, + } +} + +fn rejection(canonical: &RecipeCompleted, reproduction: &RecipeCompleted) -> String { + match check_fastly_equality(canonical, reproduction) { + Ok(_) => panic!("should reject the pair"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn identical_canonical_and_reproduction_fastly_bytes_are_accepted_with_the_standard_claim() { + let bytes = module(b"info"); + let canonical = completed( + FASTLY_ASSET, + Role::Canonical, + &bytes, + ManifestMarks::Committed, + ); + let reproduction = completed( + FASTLY_ASSET, + Role::Reproduction, + &bytes, + ManifestMarks::Committed, + ); + let checked = check_fastly_equality(&canonical, &reproduction) + .expect("should accept identical final bytes"); + assert_eq!( + checked.asset().artifact().name, + FASTLY_ASSET, + "should name Fastly" + ); + assert_eq!( + checked.sha256(), + hex::encode(Sha256::digest(&bytes)), + "should report the shared digest" + ); + assert_eq!(checked.size(), bytes.len() as u64, "should report the size"); + assert_eq!( + checked.ambient().claim(), + &AmbientClaim::standard(), + "should bind the honest ambient placement" + ); + + let measurement = super::candidate::equality_measurement(&canonical, &reproduction, &checked); + assert_eq!( + ( + &measurement["kind"], + &measurement["equality"], + &measurement["release_qualification"], + ), + ( + &serde_json::json!("ordinary-ci-fastly-canonical-reproduction-equality"), + &serde_json::json!(true), + &serde_json::json!(false), + ), + "should report candidate equality without release qualification" + ); + for (role, umask, jobs) in [("canonical", "022", 4), ("reproduction", "027", 2)] { + let build = &measurement["builds"][role]; + assert_eq!( + build["artifact"]["sha256"], + checked.sha256(), + "should report the {role} digest" + ); + assert_eq!( + (&build["ambient"]["umask"], &build["ambient"]["jobs"]), + (&serde_json::json!(umask), &serde_json::json!(jobs)), + "should report the {role} ambient record" + ); + assert_eq!( + ( + &build["ambient"]["child_locale"], + &build["ambient"]["child_timezone"] + ), + (&serde_json::json!("C"), &serde_json::json!("UTC")), + "should report the normalized {role} child locale and timezone" + ); + } + assert_eq!( + measurement["ambient_claim"]["unsupported"], + serde_json::json!(["ExecutionUser"]), + "should not claim the execution user varied" + ); +} + +#[test] +fn one_byte_difference_reports_sizes_digests_and_first_offset() { + let canonical = completed( + FASTLY_ASSET, + Role::Canonical, + &module(b"info"), + ManifestMarks::Committed, + ); + let reproduction = completed( + FASTLY_ASSET, + Role::Reproduction, + &module(b"inFo"), + ManifestMarks::Committed, + ); + let message = rejection(&canonical, &reproduction); + let offset = + first_difference(&module(b"info"), &module(b"inFo")).expect("should find the changed byte"); + for part in [ + "final Fastly bytes differ".to_owned(), + format!("first differing offset {offset}"), + format!("canonical size {}", module(b"info").len()), + canonical.artifact().sha256().to_owned(), + reproduction.artifact().sha256().to_owned(), + "custom section \".debug_info\"".to_owned(), + "1 differing bytes".to_owned(), + ] { + assert!(message.contains(&part), "should report {part}: {message}"); + } +} + +#[test] +fn first_difference_covers_prefixes_and_equal_inputs() { + assert_eq!(first_difference(b"abc", b"abc"), None, "should find none"); + assert_eq!( + first_difference(b"abc", b"abd"), + Some(2), + "should find byte 2" + ); + assert_eq!( + first_difference(b"ab", b"abc"), + Some(2), + "should report the shorter length for a strict prefix" + ); + let message = describe_mismatch(b"ab", b"abc"); + assert!( + message.contains("canonical size 2") && message.contains("reproduction size 3"), + "should report both sizes: {message}" + ); + assert!( + message.contains("1 differing bytes") && message.contains("unparsed module"), + "should count the extra byte and survive a non-module: {message}" + ); + let long = vec![0_u8; 4096]; + let mut changed = long.clone(); + changed[100] = 1; + assert!( + describe_mismatch(&long, &changed).len() < 600, + "should bound the diagnostic window" + ); +} + +#[test] +fn same_role_or_swapped_pairs_are_rejected() { + let bytes = module(b"info"); + let canonical = completed( + FASTLY_ASSET, + Role::Canonical, + &bytes, + ManifestMarks::Committed, + ); + let other_canonical = completed( + FASTLY_ASSET, + Role::Canonical, + &bytes, + ManifestMarks::Committed, + ); + let reproduction = completed( + FASTLY_ASSET, + Role::Reproduction, + &bytes, + ManifestMarks::Committed, + ); + for (first, second) in [(&canonical, &other_canonical), (&reproduction, &canonical)] { + assert!( + rejection(first, second).contains("a canonical then a reproduction build"), + "should require exactly the canonical then the reproduction build" + ); + } +} + +#[test] +fn mismatched_inputs_or_another_asset_are_rejected() { + let bytes = module(b"info"); + let canonical = completed( + FASTLY_ASSET, + Role::Canonical, + &bytes, + ManifestMarks::Committed, + ); + let derived = completed( + FASTLY_ASSET, + Role::Reproduction, + &bytes, + ManifestMarks::Derived, + ); + assert!( + rejection(&canonical, &derived).contains("derived Git manifests"), + "should reject builds over different derived manifests" + ); + let normalized = completed( + FASTLY_ASSET, + Role::Reproduction, + &bytes, + ManifestMarks::Normalized, + ); + assert!( + rejection(&canonical, &normalized).contains("producer-normalized Git manifests"), + "should reject builds over different producer-normalized manifests" + ); + let native = finish(built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + )); + let reproduction = completed( + FASTLY_ASSET, + Role::Reproduction, + &bytes, + ManifestMarks::Committed, + ); + assert!( + rejection(&native, &reproduction).contains("compares only"), + "should compare only the Fastly asset" + ); +} + +/// One forged input of the reproduction build and the label its rejection must name. +type Forge = (&'static str, fn(&mut SuccessFacts)); + +#[test] +fn each_mismatched_input_is_rejected_by_name() { + let bytes = module(b"info"); + let canonical = completed( + FASTLY_ASSET, + Role::Canonical, + &bytes, + ManifestMarks::Committed, + ); + let cases: [Forge; 8] = [ + ("source commits", |facts| { + facts.commit = CommitId::parse(OTHER_COMMIT).expect("should parse commit"); + }), + ("Cargo.lock files", |facts| { + facts.cargo_lock_sha256 = "e".repeat(64); + }), + ("vendored materials", |facts| { + facts.materials_digest = "e".repeat(64); + }), + ("JS handoffs", |facts| { + facts.js_inventory_sha256 = "e".repeat(64); + }), + ("JS handoffs", |facts| { + facts.js_settings_sha256 = "e".repeat(64) + }), + ("JS handoffs", |facts| { + facts.js_tools_sha256 = "e".repeat(64) + }), + ("Rust tools", |facts| { + facts.rust_tools_digest = "e".repeat(64); + }), + ("compares only", |facts| { + facts.asset = AssetId::parse("ts-aarch64-apple-darwin").expect("should resolve"); + }), + ]; + for (label, forge) in cases { + let mut reproduction = completed( + FASTLY_ASSET, + Role::Reproduction, + &bytes, + ManifestMarks::Committed, + ); + forge(reproduction.completed.facts_mut_for_tests()); + let message = rejection(&canonical, &reproduction); + assert!( + message.contains(label), + "should reject a reproduction build with mismatched {label}: {message}" + ); + } +} + +#[test] +fn artifact_changed_after_completion_is_rejected() { + let bytes = module(b"info"); + let canonical = completed( + FASTLY_ASSET, + Role::Canonical, + &bytes, + ManifestMarks::Committed, + ); + let reproduction = completed( + FASTLY_ASSET, + Role::Reproduction, + &bytes, + ManifestMarks::Committed, + ); + std::fs::write(reproduction.artifact().path(), module(b"inFo")) + .expect("should tamper with the reproduction build"); + assert!( + rejection(&canonical, &reproduction).contains("artifact bytes changed"), + "should rehash both artifacts before comparing" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/complete.rs b/crates/trusted-server-release/src/recipe/tests/complete.rs new file mode 100644 index 000000000..3e98d1e92 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/complete.rs @@ -0,0 +1,928 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::time::Duration; + +use serde_json::{Value, json}; +use sha2::Sha256; + +use super::complete_darwin::{darwin_executable, darwin_executable_with, rejects_embedded_path}; +use super::fixture::*; +use super::*; +use crate::boundary::DescendantCleanup; +use crate::inspect::Violation; +use crate::inspect::tests::builders::{ElfSpec, MachoSpec, elf, macho}; + +const FINGERPRINT: [u8; 32] = [7; 32]; + +#[test] +fn pure_completion_fixtures_cannot_export_measured_socket_receipts() { + let bytes = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let mut build = built("trusted-server-fastly.wasm", Role::Canonical, &bytes); + let stdout = build.stdout(|_| {}); + let completed = build + .finish(stdout) + .expect("should complete the accepted fixture artifact"); + assert!( + completed.denial_receipt().is_err(), + "should not export measured socket observations from a pure completion fixture" + ); +} + +#[test] +fn pure_completion_fixtures_cannot_export_candidate_transfers() { + let bytes = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let mut build = built("trusted-server-fastly.wasm", Role::Canonical, &bytes); + let stdout = build.stdout(|_| {}); + let completed = build + .finish(stdout) + .expect("should complete the accepted fixture artifact"); + assert!( + completed.composition_export().is_err(), + "should reject a fixture with no actual retained composition receipts" + ); + assert!( + completed.composition_view().is_err(), + "should refuse a typed owner view without actual retained composition receipts" + ); + assert!( + completed.javascript_contributions().is_err(), + "should refuse original JS evidence absent from the owning completion" + ); +} + +// Inject only a checked JS snapshot into an explicitly unmeasured completion +// fixture. This cannot create composition, socket or workflow authority. +fn completed_with_js_snapshot() -> (Built, RecipeCompleted, Vec) { + let javascript = JsBuildCompleted::fixture_for_projection(COMMIT); + let snapshot = javascript + .retained_contributions() + .expect("should validate original JS inventory before retaining it"); + let raw = snapshot.raw_inventory().to_vec(); + let bytes = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let mut build = built("trusted-server-fastly.wasm", Role::Canonical, &bytes); + let prepared = build + .prepared + .as_mut() + .expect("should hold prepared fixture"); + prepared.js.inventory_sha256 = snapshot.inventory_sha256().to_owned(); + prepared.js.contributions = Some(snapshot); + drop(javascript); + let stdout = build.stdout(|_| {}); + let completed = build + .finish(stdout) + .expect("should retain checked JS snapshot"); + (build, completed, raw) +} + +#[test] +fn completion_retains_exact_js_inventory_after_original_owner_disappears() { + let (_build, completed, raw) = completed_with_js_snapshot(); + let snapshot = completed + .javascript_contributions() + .expect("should retain checked evidence without its preparation owner or lease"); + assert_eq!( + snapshot.raw_inventory(), + raw, + "should retain original bytes" + ); + assert_eq!( + fs::read(completed.retained_inventory_path()).expect("should retain original file"), + raw, + "should copy original inventory rather than serialize normalized facts" + ); + assert!( + completed.denial_receipt().is_err(), + "should grant no measured socket authority to a checked JS snapshot" + ); +} + +#[test] +fn completion_rejects_changed_retained_js_inventory() { + let (_build, completed, _) = completed_with_js_snapshot(); + fs::write(completed.retained_inventory_path(), b"changed original") + .expect("should change retained bytes"); + assert!( + completed.javascript_contributions().is_err(), + "should reject a changed original even with unchanged normalized facts" + ); +} + +#[test] +fn completion_rejects_aliases_of_unchanged_retained_js_inventory() { + for hard_link in [false, true] { + let (_build, completed, raw) = completed_with_js_snapshot(); + let outside = tempfile::tempdir().expect("should create separate inventory storage"); + let replacement = outside.path().join("inventory.json"); + fs::write(&replacement, raw).expect("should copy identical original inventory"); + let retained = completed.retained_inventory_path(); + fs::remove_file(&retained).expect("should replace retained inventory"); + if hard_link { + fs::hard_link(&replacement, &retained).expect("should install matching hardlink"); + } else { + symlink(&replacement, &retained).expect("should install matching alias"); + } + assert!( + completed.javascript_contributions().is_err(), + "should reject aliases even when original JS inventory bytes match" + ); + } +} + +#[test] +fn completion_rejects_another_js_snapshot_source_or_digest() { + for change in [ + (|facts: &mut super::super::completed::SuccessFacts| { + facts.commit = crate::source::CommitId::parse(&"b".repeat(40)) + .expect("should parse fictional commit") + }) as fn(&mut super::super::completed::SuccessFacts), + |facts| facts.js_inventory_sha256 = "c".repeat(64), + ] { + let (_build, mut completed, _) = completed_with_js_snapshot(); + change(completed.facts_mut_for_tests()); + assert!( + completed.javascript_contributions().is_err(), + "should reject snapshot binding to another completed build" + ); + } +} + +pub(super) fn wasm_with(customs: &[(&str, &[u8])]) -> Vec { + let mut out = b"\0asm\x01\0\0\0".to_vec(); + for (name, data) in customs + .iter() + .copied() + .chain([("remapped-source", b"/trusted-server/src/main.rs".as_slice())]) + { + let mut body = vec![u8::try_from(name.len()).expect("should fit")]; + body.extend_from_slice(name.as_bytes()); + body.extend_from_slice(data); + out.push(0); + out.push(u8::try_from(body.len()).expect("should fit")); + out.extend_from_slice(&body); + } + out +} + +/// Cargo's executable file name for `asset`. +fn executable_name(asset: &str) -> String { + let artifact = AssetId::parse(asset) + .expect("should resolve asset") + .artifact(); + if artifact.target == ReleaseTarget::Wasm32Wasip1 { + format!("{}.wasm", artifact.bin) + } else { + artifact.bin.to_owned() + } +} + +pub(super) struct Built { + fixture: Fixture, + prepared: Option, + asset: &'static str, + role: Role, + cleanup: DescendantCleanup, +} + +/// Which Git manifest digests the fictional materials token records. +#[derive(Clone, Copy, Debug)] +pub(super) enum ManifestMarks { + /// Only committed manifests: nothing normalized or derived. + Committed, + /// One producer-normalized manifest. + Normalized, + /// One caller-derived manifest. + Derived, +} + +pub(super) fn built(asset: &'static str, role: Role, bytes: &[u8]) -> Built { + built_with(asset, role, bytes, ManifestMarks::Committed) +} + +/// Builds a bound recipe over materials carrying the given manifest marks. +pub(super) fn built_with( + asset: &'static str, + role: Role, + bytes: &[u8], + marks: ManifestMarks, +) -> Built { + let fixture = Fixture::new(COMMIT); + let marked = |materials: PreparedCargoMaterials| match marks { + ManifestMarks::Committed => materials, + ManifestMarks::Normalized => { + materials.with_normalized_for_tests("example-git", &"e".repeat(64)) + } + ManifestMarks::Derived => { + materials.with_derived_for_tests("example-1.0.0", &"d".repeat(64)) + } + }; + let prepared = match marks { + ManifestMarks::Committed => fixture.bind(asset, role), + ManifestMarks::Normalized | ManifestMarks::Derived => { + let request = fixture.request(asset, role.spec_label(), COMMIT); + let tools = fixture.tools_for(request.asset.artifact().target); + let materials = marked(fixture.materials(COMMIT)); + fixture.bind_parts(request, role, COMMIT, tools, materials) + } + } + .expect("should bind the recipe"); + let artifact = AssetId::parse(asset) + .expect("should resolve asset") + .artifact(); + let release = fixture + .base + .join("target") + .join(artifact.target.triple()) + .join("release"); + fs::create_dir_all(&release).expect("should create the release directory"); + fs::write(release.join(executable_name(asset)), bytes).expect("should write the executable"); + let cleanup = if artifact.target.is_darwin() { + DescendantCleanup::ReapedAfterRun + } else { + DescendantCleanup::OwnedByContainer + }; + Built { + fixture, + prepared: Some(prepared), + asset, + role, + cleanup, + } +} + +impl Built { + fn prepared(&self) -> &PreparedRecipe { + self.prepared + .as_ref() + .expect("should hold the prepared recipe") + } + + pub(super) fn stdout(&self, edit: impl FnOnce(&mut Value)) -> Vec { + let artifact = AssetId::parse(self.asset) + .expect("should resolve asset") + .artifact(); + let release = format!("/boundary/writable/1/{}/release", artifact.target.triple()); + let mut message = json!({ + "reason": "compiler-artifact", + "manifest_path": format!("/boundary/immutable/0/crates/{}/Cargo.toml", artifact.package), + "target": {"kind": ["bin"], "crate_types": ["bin"], "name": artifact.bin}, + "profile": {"opt_level": "3", "debuginfo": 1}, + "executable": format!("{release}/{}", executable_name(self.asset)), + "fresh": false + }); + edit(&mut message); + let mut out = Vec::new(); + for line in [ + json!({"reason": "build-script-executed", "env": []}), + message, + json!({"reason": "build-finished", "success": true}), + ] { + out.extend_from_slice(line.to_string().as_bytes()); + out.push(b'\n'); + } + out + } + + fn run(&self, stdout: Vec) -> RunOutput { + RunOutput { + gnu: None, + stdout, + stderr_sha256: hex::encode(Sha256::digest(b"fictional diagnostics")), + started_at_seconds: 1_700_000_000, + elapsed: Duration::from_secs(42), + cleanup: self.cleanup, + queries: super::super::execute::CargoQueryOutput::Fixture, + } + } + + pub(super) fn finish( + &mut self, + stdout: Vec, + ) -> Result> { + let paths = self.fixture.paths(self.role); + let run = self.run(stdout); + self.prepared + .take() + .expect("should hold the prepared recipe") + .complete(run, FINGERPRINT, &paths) + } +} + +fn rejection(result: Result>) -> String { + match result { + Ok(_) => panic!("should reject the completion"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn successful_run_binds_every_input_and_copies_the_artifact() { + let bytes = darwin_executable(); + let mut built = built("ts-aarch64-apple-darwin", Role::Canonical, &bytes); + let stdout = built.stdout(|_| {}); + let expected_plan = built + .prepared() + .plan(&built.fixture.paths(Role::Canonical)) + .expect("should derive the plan") + .digest() + .expect("should digest the plan"); + let materials_digest = built.prepared().materials.digest().to_owned(); + let tools_digest = built.prepared().tools_digest.clone(); + let js = [ + built.fixture.completed.inventory_sha256().to_owned(), + built.fixture.completed.settings_sha256().to_owned(), + built.fixture.completed.tools_sha256().to_owned(), + ]; + let output = built.fixture.base.join("output"); + let completed = built + .finish(stdout.clone()) + .expect("should complete the run"); + + assert_eq!( + completed.asset().artifact().name, + "ts-aarch64-apple-darwin", + "should bind the asset" + ); + assert_eq!(completed.role(), Role::Canonical, "should bind the role"); + assert_eq!( + completed.commit().as_str(), + COMMIT, + "should bind the commit" + ); + assert_eq!( + completed.cargo_lock_sha256(), + "b".repeat(64), + "should bind the lock digest" + ); + assert_eq!( + completed.materials_digest(), + materials_digest, + "should bind the materials digest" + ); + assert_eq!( + completed.js_digests(), + js.each_ref().map(String::as_str), + "should bind the JS handoff" + ); + assert_eq!( + completed.rust_tools_digest(), + tools_digest, + "should bind the Rust tools digest" + ); + assert_eq!( + completed.boundary_fingerprint(), + FINGERPRINT, + "should bind the boundary instance" + ); + assert_eq!( + completed.plan_digest(), + expected_plan, + "should bind the exact plan" + ); + assert_eq!( + completed.ambient().role(), + Role::Canonical, + "should bind the ambient" + ); + assert_eq!( + completed.started_at_seconds(), + 1_700_000_000, + "should bind the start time" + ); + assert_eq!( + completed.elapsed(), + Duration::from_secs(42), + "should bind the elapsed time" + ); + assert_eq!( + completed.stdout_sha256(), + hex::encode(Sha256::digest(&stdout)), + "should bind the Cargo stream" + ); + assert_eq!( + completed.stderr_sha256(), + hex::encode(Sha256::digest(b"fictional diagnostics")), + "should bind the diagnostics digest" + ); + assert_eq!( + completed.artifact().name(), + "ts-aarch64-apple-darwin", + "should name the artifact" + ); + assert_eq!( + completed.artifact().size(), + bytes.len() as u64, + "should record the size" + ); + assert_eq!( + completed.artifact().sha256(), + hex::encode(Sha256::digest(&bytes)), + "should record the digest" + ); + assert_eq!( + fs::read(output.join("ts-aarch64-apple-darwin")).expect("should read output"), + bytes, + "should copy the bytes" + ); + assert_eq!( + completed + .rehash_for_handoff() + .expect("should rehash the untouched output"), + output, + "should return the output directory" + ); +} + +#[test] +fn rehash_rejects_changed_output_and_retained_stream() { + type Tamper = fn(&std::path::Path, &RecipeCompleted); + let tampers: [(&str, Tamper); 6] = [ + ("edit", |output, _| { + fs::write( + output.join("ts-aarch64-apple-darwin"), + b"tampered executable", + ) + .expect("should edit"); + }), + ("extra entry", |output, _| { + fs::write(output.join("extra"), b"x").expect("should add an entry"); + }), + ("hard link", |output, _| { + fs::hard_link( + output.join("ts-aarch64-apple-darwin"), + output.join("..").join("link"), + ) + .expect("should link"); + }), + ("removal", |output, _| { + fs::remove_file(output.join("ts-aarch64-apple-darwin")).expect("should remove"); + }), + ("symlink swap", |output, _| { + let path = output.join("ts-aarch64-apple-darwin"); + fs::remove_file(&path).expect("should remove"); + fs::write(output.join("..").join("elsewhere"), darwin_executable()) + .expect("should write target"); + symlink(output.join("..").join("elsewhere"), path).expect("should swap in a symlink"); + }), + ("retained stream", |_, completed| { + fs::write(completed.retained_stdout_path(), b"tampered stream").expect("should edit"); + }), + ]; + for (label, tamper) in tampers { + let mut built = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + let stdout = built.stdout(|_| {}); + let output = built.fixture.base.join("output"); + let completed = built.finish(stdout).expect("should complete the run"); + completed + .rehash_for_handoff() + .expect("should rehash before tampering"); + tamper(&output, &completed); + assert!( + completed.rehash_for_handoff().is_err(), + "should reject {label} before handoff" + ); + } +} + +#[test] +fn malformed_streams_complete_nothing_and_copy_nothing() { + type Edit = fn(&mut Value); + let edits: [(&str, Edit); 5] = [ + ("fresh", |m| m["fresh"] = json!(true)), + ("debuginfo", |m| m["profile"]["debuginfo"] = json!(0)), + ("name", |m| m["target"]["name"] = json!("other")), + ("outside executable", |m| { + m["executable"] = json!("/boundary/immutable/0/ts"); + }), + ("executable in a read-only root", |m| { + m["executable"] = json!("/boundary/immutable/0/target/release/ts"); + }), + ]; + for (label, edit) in edits { + let mut built = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + let stdout = built.stdout(edit); + let output = built.fixture.base.join("output"); + assert!( + built.finish(stdout).is_err(), + "should reject a stream with {label}" + ); + assert_eq!( + fs::read_dir(output).expect("should list output").count(), + 0, + "should copy nothing after a {label} rejection" + ); + } +} + +#[test] +fn changed_tools_or_materials_or_existing_output_reject_completion() { + let mut built_tools = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + let stdout = built_tools.stdout(|_| {}); + fs::write(built_tools.fixture.base.join("tools/bin/cargo"), b"swapped") + .expect("should swap cargo"); + assert!( + rejection(built_tools.finish(stdout)).contains("Rust tools changed"), + "should reject tools changed after the run" + ); + + let mut built_materials = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + let stdout = built_materials.stdout(|_| {}); + fs::write( + built_materials + .fixture + .base + .join("materials/vendor/example-1.0.0/Cargo.toml"), + b"swapped", + ) + .expect("should swap a dependency"); + assert!( + rejection(built_materials.finish(stdout)).contains("changed after binding"), + "should reject materials changed after the run" + ); + + let mut built_output = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + let stdout = built_output.stdout(|_| {}); + fs::write( + built_output + .fixture + .base + .join("output/ts-aarch64-apple-darwin"), + b"planted", + ) + .expect("should plant an output"); + assert!( + rejection(built_output.finish(stdout)).contains("exclusively"), + "should reject an output that exists already" + ); +} + +#[test] +fn wasm_artifacts_must_keep_debug_sections() { + let asset = "trusted-server-fastly.wasm"; + let stripped = wasm_with(&[("name", b"n")]); + let mut built_stripped = built(asset, Role::Canonical, &stripped); + let stdout = built_stripped.stdout(|_| {}); + let message = rejection(built_stripped.finish(stdout)); + assert!( + message.contains("debug section") || message.contains("WasmDebugInfo"), + "should reject a stripped module: {message}" + ); + + let kept = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let mut built_kept = built(asset, Role::Canonical, &kept); + let stdout = built_kept.stdout(|_| {}); + let completed = built_kept + .finish(stdout) + .expect("should accept a module with debug sections"); + assert_eq!( + completed.artifact().name(), + asset, + "should publish under the asset name" + ); + + let mut built_text = built(asset, Role::Canonical, b"not wasm at all"); + let stdout = built_text.stdout(|_| {}); + assert!( + built_text.finish(stdout).is_err(), + "should reject bytes that are not a module" + ); +} + +/// Completes a Linux `ts` run whose executable holds `bytes`. +fn finish_linux(bytes: &[u8]) -> Result> { + let mut built = built("ts-x86_64-unknown-linux-gnu", Role::Canonical, bytes); + let stdout = built.stdout(|_| {}); + built.finish(stdout) +} + +#[test] +fn linux_executables_must_pass_elf_inspection_and_keep_debug_info() { + let completed = finish_linux(&elf(&ElfSpec { + payload: b"/trusted-server/src/main.rs", + ..ElfSpec::conforming() + })) + .expect("should accept a conforming executable with debug info"); + assert_eq!( + completed.artifact().name(), + "ts-x86_64-unknown-linux-gnu", + "should publish under the asset name" + ); + let cases: [(&str, ElfSpec, Violation); 3] = [ + ( + "stripped", + ElfSpec { + debug_sections: Vec::new(), + ..ElfSpec::conforming() + }, + Violation::ElfDebugInfo, + ), + ( + "above the glibc ceiling", + ElfSpec { + requirements: vec![("libc.so.6", vec!["GLIBC_2.35"])], + ..ElfSpec::conforming() + }, + Violation::SymbolVersion, + ), + ( + "runner path", + ElfSpec { + payload: b"panic at /home/runner/work/example/src/main.rs", + ..ElfSpec::conforming() + }, + Violation::EmbeddedPath, + ), + ]; + for (label, spec, expected) in cases { + let error = finish_linux(&elf(&spec)).expect_err("should reject the executable"); + assert_eq!( + error.downcast_ref::(), + Some(&expected), + "should reject the {label} executable by its rule: {error:?}" + ); + } + assert!( + finish_linux(b"fictional text, not an executable").is_err(), + "should reject bytes that are not ELF" + ); +} + +#[test] +fn execute_stays_closed_for_an_ordinary_ci_selection() { + let built = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + assert_eq!( + built.prepared().tools.authority(), + Authority::Unqualified, + "should carry no release authority into the execute gate" + ); +} + +#[test] +fn manifest_digests_are_carried_into_the_completion() { + let finish = |marks: ManifestMarks| { + let mut built = built_with( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + marks, + ); + let stdout = built.stdout(|_| {}); + let completed = built.finish(stdout).expect("should complete the run"); + (built, completed) + }; + let (_plain, completed) = finish(ManifestMarks::Committed); + assert!( + completed.derived_manifest_digests().is_empty() + && completed.normalized_manifest_digests().is_empty(), + "should carry no normalized or derived manifest when none was used" + ); + + let (_normalized, completed) = finish(ManifestMarks::Normalized); + assert_eq!( + completed.normalized_manifest_digests().get("example-git"), + Some(&"e".repeat(64)), + "should carry the producer-normalized manifest digest" + ); + assert!( + completed.derived_manifest_digests().is_empty(), + "should not treat a producer-normalized manifest as derived" + ); + + let (_derived, completed) = finish(ManifestMarks::Derived); + assert_eq!( + completed.derived_manifest_digests().get("example-1.0.0"), + Some(&"d".repeat(64)), + "should carry the derived manifest digest so the build stays marked unqualified" + ); +} + +#[test] +fn candidate_measurements_report_no_release_qualification() { + let asset = "trusted-server-fastly.wasm"; + let kept = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let mut built = built_with(asset, Role::Canonical, &kept, ManifestMarks::Normalized); + let stdout = built.stdout(|_| {}); + let completed = built.finish(stdout).expect("should complete the run"); + + let measurement = super::candidate::cargo_measurement(&completed); + assert_eq!( + measurement["kind"], "ordinary-ci-confined-cargo-build", + "should name the candidate kind" + ); + assert_eq!( + measurement["release_qualification"], false, + "should never claim release qualification" + ); + assert_eq!(measurement["asset"], asset, "should name the asset"); + assert_eq!(measurement["role"], "canonical", "should name the role"); + assert_eq!( + measurement["source_commit"], COMMIT, + "should bind the commit" + ); + assert_eq!( + measurement["artifact"]["sha256"], + hex::encode(Sha256::digest(&kept)), + "should report the copied artifact digest" + ); + assert_eq!( + measurement["artifact"]["size"], + kept.len(), + "should report the copied artifact size" + ); + assert_eq!( + measurement["normalized_manifest_count"], 1, + "should count the producer-normalized manifests" + ); + assert_eq!( + measurement["normalized_manifest_digests"]["example-git"], + "e".repeat(64), + "should report each producer-normalized manifest digest" + ); + assert_eq!( + measurement["derived_manifest_digests"], 0, + "should report that no caller-derived manifest was consumed" + ); + for field in ["rust_tools_digest", "materials_digest", "plan_digest"] { + assert_eq!( + measurement[field].as_str().map(str::len), + Some(64), + "should report the {field}" + ); + } + + let observed = std::collections::BTreeMap::from([("build-direct".to_owned(), libc::EPERM)]); + let denial = super::candidate::denial_measurement(libc::EPERM, &observed, &completed); + assert_eq!( + denial["attempts"]["build-direct"]["errno"], + libc::EPERM, + "should report each observed errno" + ); + assert_eq!( + denial["release_qualification"], false, + "should never claim release qualification" + ); +} + +/// Maps the bound roots into an innocuous namespace without `/boundary/`. +fn innocuous_paths(built: &Built) -> super::super::paths::PrefixPaths { + let prepared = built.prepared(); + super::super::paths::PrefixPaths::new() + .with(prepared.ambient.checkout_root(), "/work/source", false) + .with(&prepared.retained_root, "/work/scripts", false) + .with(&prepared.request.materials, "/work/dependencies", false) + .with(prepared.tools.root(), "/work/compiler", false) + .with(&prepared.request.cargo_state, "/work/state", true) + .with(&prepared.request.target_dir, "/work/products", true) + .with(&built.fixture.developer_dir(), "/work/developer", false) +} + +#[test] +fn completion_rejects_every_bound_host_and_execution_root() { + for index in 0..15 { + let mut build = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + let paths = innocuous_paths(&build); + let prepared = build.prepared(); + let host = [ + prepared.ambient.checkout_root().to_owned(), + prepared.retained_root.clone(), + prepared.request.materials.clone(), + prepared.tools.root().to_owned(), + prepared.request.cargo_state.clone(), + prepared.request.target_dir.clone(), + build.fixture.developer_dir(), + ]; + let mut denied = host.to_vec(); + denied.extend(host.iter().map(|root| { + paths + .execution_path(root) + .expect("should translate a bound root") + })); + denied.push(prepared.request.output.clone()); + let leaked = denied[index].join("generated/source.rs"); + let payload = format!( + "/trusted-server/src/main.rs env!(CARGO_MANIFEST_DIR)={}", + leaked.display() + ); + let target = prepared + .request + .target_dir + .join("aarch64-apple-darwin/release/ts"); + fs::write(&target, darwin_executable_with(payload.as_bytes())) + .expect("should plant the literal in the executable"); + let stdout = build.stdout(|message| { + message["manifest_path"] = json!("/work/source/crates/trusted-server-cli/Cargo.toml"); + message["executable"] = json!("/work/products/aarch64-apple-darwin/release/ts"); + }); + let run = build.run(stdout); + let result = build + .prepared + .take() + .expect("should retain the prepared recipe") + .complete(run, FINGERPRINT, &paths); + let error = rejection(result); + assert!( + rejects_embedded_path(&error), + "should reject bound root {} by final-path gate: {error}", + denied[index].display() + ); + } +} + +#[test] +fn completion_rejects_cargo_state_home_and_temporary_literals() { + for child in ["home", "tmp", "cargo-home"] { + let mut build = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + let paths = innocuous_paths(&build); + let payload = format!(" env!(OUT_DIR)=/work/state/{child}/generated.rs"); + fs::write( + build + .prepared() + .request + .target_dir + .join("aarch64-apple-darwin/release/ts"), + darwin_executable_with(payload.as_bytes()), + ) + .expect("should plant cargo-state descendant"); + let stdout = build.stdout(|message| { + message["manifest_path"] = json!("/work/source/crates/trusted-server-cli/Cargo.toml"); + message["executable"] = json!("/work/products/aarch64-apple-darwin/release/ts"); + }); + let run = build.run(stdout); + let result = build + .prepared + .take() + .expect("should retain the prepared recipe") + .complete(run, FINGERPRINT, &paths); + let error = rejection(result); + assert!( + rejects_embedded_path(&error), + "should reject {child} outside remapped Cargo home too: {error}" + ); + } +} + +#[test] +fn completion_requires_a_fixed_remapped_prefix() { + let mut build = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &macho(&MachoSpec::conforming()), + ); + let stdout = build.stdout(|_| {}); + assert!( + rejection(build.finish(stdout)).contains("no remapped path prefix"), + "should require proof of remapping before recording completion" + ); +} + +#[test] +fn completion_accepts_fixed_prefixes_in_an_innocuous_execution_namespace() { + let mut build = built( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable_with(b" /cargo-target/generated/file.rs"), + ); + let paths = innocuous_paths(&build); + let stdout = build.stdout(|message| { + message["manifest_path"] = json!("/work/source/crates/trusted-server-cli/Cargo.toml"); + message["executable"] = json!("/work/products/aarch64-apple-darwin/release/ts"); + }); + let run = build.run(stdout); + build + .prepared + .take() + .expect("should retain prepared recipe") + .complete(run, FINGERPRINT, &paths) + .expect("should complete only remapped final bytes without requiring Linux mount names"); +} diff --git a/crates/trusted-server-release/src/recipe/tests/complete_darwin.rs b/crates/trusted-server-release/src/recipe/tests/complete_darwin.rs new file mode 100644 index 000000000..36f97fd1a --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/complete_darwin.rs @@ -0,0 +1,173 @@ +//! Completion of synthetic Darwin builds: Mach-O acceptance, the measured +//! SDK and the recorded descendant cleanup. + +use object::macho as macho_format; + +use super::complete::built; +use super::*; +use crate::boundary::DescendantCleanup; +use crate::inspect::Violation; +use crate::inspect::tests::builders::{ + Command, MachoSpec, Symbol, debug_map_symbols, macho, macos, +}; + +/// A conforming linked arm64 `ts` whose payload holds the remapped source +/// prefix the final-path gate requires. +pub(super) fn darwin_executable() -> Vec { + darwin_executable_with(b"") +} + +/// The same executable with `extra` bytes appended after its signature. +pub(super) fn darwin_executable_with(extra: &[u8]) -> Vec { + let mut bytes = macho(&darwin_spec()); + bytes.extend_from_slice(extra); + bytes +} + +/// Reports whether a completion error rejected an embedded path: by the +/// final-path gate, or first by the Mach-O inspector, whose runner-path rule +/// also matches temporary host roots and any `/home/` segment. +pub(super) fn rejects_embedded_path(error: &str) -> bool { + error.contains("unremapped path prefix") || error.contains("runner-only path") +} + +fn darwin_spec() -> MachoSpec { + MachoSpec { + payload: b"/trusted-server/src/main.rs", + ..MachoSpec::conforming() + } +} + +/// Completes a synthetic build of `asset` holding exactly `bytes`. +fn finish(asset: &'static str, bytes: &[u8]) -> Result> { + let mut built = built(asset, Role::Canonical, bytes); + let stdout = built.stdout(|_| {}); + built.finish(stdout) +} + +fn replaced(index: usize, command: Command) -> Vec { + let mut spec = darwin_spec(); + spec.commands[index] = command; + macho(&spec) +} + +fn position(matches: impl Fn(&Command) -> bool) -> usize { + darwin_spec().position(matches) +} + +#[test] +fn conforming_darwin_executables_complete_with_post_run_reaped_cleanup() { + let completed = finish("ts-aarch64-apple-darwin", &darwin_executable()) + .expect("should accept a conforming arm64 executable"); + assert_eq!( + completed.descendant_cleanup(), + DescendantCleanup::ReapedAfterRun, + "should record that macOS survivors are reaped after the run" + ); + let measurement = super::candidate::cargo_measurement(&completed); + assert_eq!( + measurement["descendant_cleanup"], "post-run-reaped", + "should report the cleanup mode in the candidate measurement" + ); + + let mut x86_64 = darwin_spec(); + x86_64.cputype = macho_format::CPU_TYPE_X86_64; + x86_64.cpusubtype = macho_format::CPU_SUBTYPE_X86_64_ALL; + finish("ts-x86_64-apple-darwin", &macho(&x86_64)) + .expect("should accept a conforming x86_64 executable"); +} + +#[test] +fn linux_completion_records_container_owned_cleanup() { + let wasm = super::complete::wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let completed = + finish("trusted-server-fastly.wasm", &wasm).expect("should accept the Wasm module"); + assert_eq!( + super::candidate::cargo_measurement(&completed)["descendant_cleanup"], + "owned-by-container", + "should report container-owned cleanup" + ); +} + +#[test] +fn darwin_executables_outside_the_policy_are_rejected_by_rule() { + let build_version = position(|command| matches!(command, Command::BuildVersion { .. })); + let symtab = position(|command| matches!(command, Command::Symtab(_))); + let mut absolute = debug_map_symbols(); + absolute.push(Symbol { + n_type: macho_format::N_OSO, + name: "/opt/example/cargo-target/release/deps/ts.o", + value: 0, + }); + let mut unsigned = darwin_spec(); + unsigned + .commands + .retain(|command| !matches!(command, Command::CodeSignature(_))); + let cases: [(&str, &'static str, Vec, Violation); 5] = [ + ( + "minimum macOS 11", + "ts-aarch64-apple-darwin", + replaced( + build_version, + Command::BuildVersion { + platform: macho_format::PLATFORM_MACOS, + minos: macos(11, 0), + }, + ), + Violation::DeploymentTarget, + ), + ( + "an absolute debug-map object", + "ts-aarch64-apple-darwin", + replaced(symtab, Command::Symtab(absolute)), + Violation::MachoDebugMap, + ), + ( + "a stripped debug map", + "ts-aarch64-apple-darwin", + replaced(symtab, Command::Symtab(Vec::new())), + Violation::MachoDebugMap, + ), + ( + "an unsigned arm64 executable", + "ts-aarch64-apple-darwin", + macho(&unsigned), + Violation::CodeSignature, + ), + ( + "an arm64 executable for the x86_64 asset", + "ts-x86_64-apple-darwin", + darwin_executable(), + Violation::Architecture, + ), + ]; + for (label, asset, bytes, expected) in cases { + let error = finish(asset, &bytes).expect_err("should reject the executable"); + assert_eq!( + error.downcast_ref::(), + Some(&expected), + "should reject {label} by its rule: {error:?}" + ); + } +} + +#[test] +fn darwin_executables_must_record_the_measured_sdk() { + let build_version = position(|command| matches!(command, Command::BuildVersion { .. })); + let other_sdk = replaced( + build_version, + Command::BuildVersionSdk { + platform: macho_format::PLATFORM_MACOS, + minos: macos(13, 0), + sdk: macos(26, 1), + }, + ); + + let error = finish("ts-aarch64-apple-darwin", &other_sdk) + .expect_err("should reject an executable linked against another SDK"); + + assert!( + format!("{error:?}").contains("but the bound Apple SDK is 15.5.0"), + "should name the measured SDK: {error:?}" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/darwin.rs b/crates/trusted-server-release/src/recipe/tests/darwin.rs new file mode 100644 index 000000000..6a7d24a33 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/darwin.rs @@ -0,0 +1,679 @@ +//! Apple developer tool records, Darwin binding and the exact Darwin plan. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::super::apple::{plist_string, sdk_settings_version}; +use super::super::paths::PrefixPaths; +use super::fixture::*; +use super::*; +use crate::process::Cancellation; + +fn detached_apple(fixture: &Fixture) -> AppleDeveloperTools { + AppleDeveloperTools::with_detached_sdk_for_tests( + fixture.apple_facts(), + Instant::now() + Duration::from_secs(60), + &Cancellation::default(), + ) + .expect("should stage selected SDK") +} + +#[test] +fn owned_sdk_identity_is_path_independent_and_binds_the_selected_tree() { + let fixture = Fixture::new(COMMIT); + let first = detached_apple(&fixture); + let second = detached_apple(&fixture); + assert_ne!( + first.sdk_root(), + second.sdk_root(), + "should use independent private roots" + ); + assert_eq!( + first.identity_sha256().expect("should identify first SDK"), + second + .identity_sha256() + .expect("should identify second SDK"), + "should not bind random snapshot paths" + ); + fs::write( + fixture.apple_facts().sdk_root.join("example.h"), + b"new selected SDK input", + ) + .expect("should add SDK header"); + let changed = detached_apple(&fixture); + assert_ne!( + first.identity_sha256().expect("should identify first SDK"), + changed + .identity_sha256() + .expect("should identify changed SDK"), + "should bind complete tree digest into Apple identity" + ); + first + .verify() + .expect("should independently retain original selected SDK"); +} + +#[test] +fn owned_sdk_closure_rechecks_headers_stubs_and_frameworks_and_retains_clone_lifetime() { + for relative in [ + "usr/include/example.h", + "usr/lib/libExample.tbd", + "System/Library/Frameworks/Example.framework/Example", + ] { + let fixture = Fixture::new(COMMIT); + let source = fixture.apple_facts().sdk_root.join(relative); + fs::create_dir_all(source.parent().expect("should have parent")) + .expect("should create SDK directories"); + fs::write(source, b"selected SDK input").expect("should write SDK input"); + let apple = detached_apple(&fixture); + let clone = apple.clone(); + let root = apple.sdk_root().to_owned(); + let identity = apple.identity_sha256().expect("should measure identity"); + drop(apple); + clone + .verify() + .expect("should retain SDK while clone exists"); + let value = + serde_json::to_value(clone.candidate_measurement().expect("should measure SDK")) + .expect("should serialize SDK"); + assert!( + value["sdk_tree"]["tree_sha256"] + .as_str() + .is_some_and(|digest| digest.len() == 64), + "should bind typed SDK receipt" + ); + assert_eq!( + value["coverage_exclusions"], + serde_json::json!([ + "tool_helpers", + "liblto", + "clang_resource_directory", + "toolchain_libraries" + ]), + "should remove only covered SDK exclusions" + ); + fs::write(root.join(relative), b"changed SDK input").expect("should mutate owned input"); + assert_eq!( + clone + .identity_sha256() + .expect("should retain original identity"), + identity, + "should retain original digest for comparison" + ); + assert!( + clone.verify().is_err(), + "should detect changed {relative} while four existing file hashes remain unchanged" + ); + drop(clone); + assert!(!root.exists(), "should remove SDK after last clone"); + } +} + +#[test] +fn detached_sdk_is_the_actual_readonly_plan_sysroot_and_has_stable_remaps() { + let fixture = Fixture::new(COMMIT); + let apple = detached_apple(&fixture); + let root = apple.sdk_root().to_owned(); + let tools = fixture + .rust_tools_for(ReleaseTarget::Aarch64AppleDarwin) + .with_apple_developer_tools(apple); + let prepared = fixture + .bind_parts( + fixture.request("ts-aarch64-apple-darwin", "A", COMMIT), + Role::Canonical, + COMMIT, + tools, + fixture.materials(COMMIT), + ) + .expect("should bind detached SDK"); + assert!( + prepared + .boundary_roots() + .expect("should bind roots") + .immutable() + .contains(&root), + "should scan SDK as readonly owned root" + ); + let plan = prepared + .plan(&mac_paths(&fixture).with(&root, root.to_str().expect("should be UTF-8"), false)) + .expect("should plan selected SDK"); + assert_eq!( + plan.environment["SDKROOT"], + text(&root), + "should select copy as SDKROOT" + ); + let cflags = &plan.environment["CFLAGS_aarch64_apple_darwin"]; + for flag in [ + format!("-isysroot {}", text(&root)), + format!("-ffile-prefix-map={}=/apple-sdk", text(&root)), + format!("-fdebug-prefix-map={}=/apple-sdk", text(&root)), + ] { + assert!( + cflags.contains(&flag), + "should use selected SDK C flag {flag}" + ); + } + let flags = plan.arguments.join(" "); + assert!( + flags.contains(&format!("--remap-path-prefix={}=/apple-sdk", text(&root))), + "should remap selected SDK in Rust" + ); + assert!( + flags.contains("-Clink-arg=-isysroot") + && flags.contains(&format!("-Clink-arg={}", text(&root))), + "should explicitly select linker SDK" + ); + let paths = mac_paths(&fixture).with(&root, "/selected-sdk", false); + for prefix in [text(&root), "/selected-sdk".to_owned()] { + let bytes = format!("/trusted-server/src/main.rs {prefix}/usr/include/example.h"); + assert!( + prepared + .check_final_paths(bytes.as_bytes(), &paths) + .is_err(), + "should reject literal SDK path {prefix} in final bytes" + ); + } +} + +#[test] +fn candidate_apple_evidence_records_only_measured_files_and_explicit_exclusions() { + let fixture = Fixture::new(COMMIT); + let apple = fixture.apple_tools(); + let evidence = apple + .candidate_measurement() + .expect("should export checked Apple candidate evidence"); + let value = serde_json::to_value(evidence).expect("should serialize Apple candidate evidence"); + assert_eq!( + value["identity_sha256"], + apple.identity_sha256().expect("should bind Apple identity"), + "should preserve the bound identity record" + ); + assert_eq!( + value["tools"][0]["sha256"], + fixture.apple_facts().clang_sha256, + "should retain actual clang bytes identity" + ); + assert_eq!( + value["sdk_settings"]["sha256"], + fixture.apple_facts().sdk_settings_sha256, + "should retain SDK settings bytes identity" + ); + assert_eq!( + value["xcode_version"], "16.4", + "should retain measured Xcode version" + ); + assert_eq!( + value["sdk_version"], "15.5.0", + "should retain measured SDK version" + ); + assert_eq!( + value["origin"], "candidate_measurement", + "should not authenticate original acquisitions" + ); + assert_eq!( + value["qualification"], "unqualified", + "should not grant release authority" + ); + assert_eq!( + value["coverage_exclusions"], + serde_json::json!([ + "sdk_headers", + "sdk_stubs", + "sdk_frameworks", + "tool_helpers", + "liblto", + "clang_resource_directory", + "toolchain_libraries" + ]), + "should explicitly retain every unmeasured SDK/tool closure class" + ); + let serialized = serde_json::to_string(&value).expect("should serialize measurement"); + assert!( + !serialized.contains(fixture.base.to_str().expect("should have UTF-8 root")), + "should serialize relative component roles without host roots" + ); +} + +#[test] +fn candidate_apple_evidence_rechecks_every_owned_measured_file() { + for relative in [ + "Toolchains/XcodeDefault.xctoolchain/usr/bin/clang", + "Toolchains/XcodeDefault.xctoolchain/usr/bin/ar", + "Toolchains/XcodeDefault.xctoolchain/usr/bin/ld", + "Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk/SDKSettings.json", + ] { + let fixture = Fixture::new(COMMIT); + let apple = fixture.apple_tools(); + fs::write( + fixture.developer_dir().join(relative), + b"changed measured input", + ) + .expect("should alter selected Apple input"); + assert!( + apple.candidate_measurement().is_err(), + "should reject changed {relative}" + ); + } +} + +/// The macOS boundary namespace: every root keeps its host path. +fn mac_paths(fixture: &Fixture) -> PrefixPaths { + let identity = |paths: PrefixPaths, root: &Path, writable: bool| { + paths.with(root, root.to_str().expect("should be UTF-8"), writable) + }; + let mut paths = PrefixPaths::new(); + for root in [ + fixture.base.join("checkout-canonical"), + fixture.retained(), + fixture.base.join("materials"), + fixture.base.join("tools"), + fixture.developer_dir(), + ] { + paths = identity(paths, &root, false); + } + for root in [ + fixture.base.join("cargo-state"), + fixture.base.join("target"), + ] { + paths = identity(paths, &root, true); + } + paths +} + +fn text(path: &Path) -> String { + path.to_str().expect("should be UTF-8").to_owned() +} + +#[test] +fn apple_tool_facts_are_checked_before_any_use() { + let fixture = Fixture::new(COMMIT); + AppleDeveloperTools::from_facts_for_tests(fixture.apple_facts()) + .expect("should accept the fictional developer directory"); + type Edit = fn(&mut AppleToolFacts, &Path); + let edits: [(&str, Edit); 10] = [ + ("a relative developer directory", |facts, _| { + facts.developer_dir = PathBuf::from("apple-developer"); + }), + ("clang outside the developer directory", |facts, base| { + facts.clang = base.join("tools/bin/clang"); + }), + ("an SDK outside the developer directory", |facts, base| { + facts.sdk_root = base.join("other-source"); + }), + ("the developer directory as its own SDK", |facts, _| { + facts.sdk_root = facts.developer_dir.clone(); + }), + ("a misnamed archiver", |facts, _| { + facts.ar = facts.clang.with_file_name("llvm-ar"); + }), + ("a linker beside another directory", |facts, _| { + facts.ld = facts.developer_dir.join("usr/bin/ld"); + }), + ("whitespace in a path", |facts, _| { + facts.developer_dir = facts.developer_dir.join("Xcode 16.app"); + }), + ("a non-decimal Xcode version", |facts, _| { + facts.xcode_version = "16.4 beta".to_owned(); + }), + ("an uppercase digest", |facts, _| { + facts.clang_sha256 = facts.clang_sha256.to_ascii_uppercase(); + }), + ("a short digest", |facts, _| { + facts.sdk_settings_sha256.truncate(63); + }), + ]; + for (label, edit) in edits { + let mut facts = fixture.apple_facts(); + edit(&mut facts, &fixture.base); + assert!( + AppleDeveloperTools::from_facts_for_tests(facts).is_err(), + "should reject {label}" + ); + } +} + +#[test] +fn qualified_apple_tools_stay_closed_and_ordinary_ci_is_unqualified() { + let never: fn(QualifiedAppleTools) -> ! = |qualification| match qualification {}; + let _constructor: fn(AppleToolFacts, QualifiedAppleTools) -> AppleDeveloperTools = + AppleDeveloperTools::qualified; + let _ = never; + let fixture = Fixture::new(COMMIT); + assert_eq!( + fixture.apple_tools().authority(), + Authority::Unqualified, + "should carry no release authority" + ); +} + +#[test] +fn sdk_settings_and_bundle_versions_are_read_strictly() { + assert_eq!( + sdk_settings_version(br#"{"Version":"15.5","DisplayName":"macOS 15.5"}"#) + .expect("should read the SDK version"), + crate::inspect::MacosVersion::new(15, 5) + ); + for settings in [ + &br#"{"Version":"15"}"#[..], + br#"{"Version":15.5}"#, + b"not json", + ] { + assert!( + sdk_settings_version(settings).is_err(), + "should reject {}", + String::from_utf8_lossy(settings) + ); + } + let plist = + b"\n\tCFBundleShortVersionString\n\t16.4\n"; + assert_eq!( + plist_string(plist, "CFBundleShortVersionString").expect("should read the version"), + "16.4" + ); + assert!( + plist_string(plist, "CFBundleVersion").is_err(), + "should reject an absent key" + ); +} + +#[test] +fn apple_tools_rehash_against_their_record() { + let fixture = Fixture::new(COMMIT); + let tools = fixture.apple_tools(); + tools.verify().expect("should accept the untouched record"); + let clang = fixture.developer_dir().join(TOOLCHAIN_BIN).join("clang"); + fs::write(&clang, b"swapped compiler").expect("should swap clang"); + assert!( + format!( + "{:?}", + tools.verify().expect_err("should reject a swapped clang") + ) + .contains("Apple clang changed"), + "should name the swapped tool" + ); + fs::write(&clang, b"fictional Apple clang").expect("should restore clang"); + tools.verify().expect("should accept the restored clang"); + let settings = fixture + .developer_dir() + .join(SDK_ROOT) + .join("SDKSettings.json"); + fs::write(&settings, br#"{"Version":"26.1"}"#).expect("should swap the SDK settings"); + assert!( + tools.verify().is_err(), + "should reject changed SDK settings" + ); +} + +#[test] +fn darwin_binding_requires_apple_tools_and_others_refuse_them() { + let fixture = Fixture::new(COMMIT); + let darwin = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let bare = fixture.rust_tools_for(ReleaseTarget::Aarch64AppleDarwin); + let message = bind_error(fixture.bind_parts( + darwin, + Role::Canonical, + COMMIT, + bare, + fixture.materials(COMMIT), + )); + assert!( + message.contains("Darwin assets require Apple developer tools"), + "should require Apple tools for Darwin: {message}" + ); + + let fixture = Fixture::new(COMMIT); + let linux = fixture.request("ts-x86_64-unknown-linux-gnu", "A", COMMIT); + let with_apple = fixture + .rust_tools_for(ReleaseTarget::X86_64UnknownLinuxGnu) + .with_apple_developer_tools(fixture.apple_tools()); + let message = bind_error(fixture.bind_parts( + linux, + Role::Canonical, + COMMIT, + with_apple, + fixture.materials(COMMIT), + )); + assert!( + message.contains("only Darwin assets take Apple developer tools"), + "should refuse Apple tools for Linux: {message}" + ); +} + +#[test] +fn apple_tools_changed_before_binding_are_rejected() { + let fixture = Fixture::new(COMMIT); + let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); + fs::write( + fixture.developer_dir().join(TOOLCHAIN_BIN).join("ld"), + b"swapped linker", + ) + .expect("should swap ld"); + let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let message = bind_error(fixture.bind_parts( + request, + Role::Canonical, + COMMIT, + tools, + fixture.materials(COMMIT), + )); + assert!( + message.contains("Apple ld changed"), + "should reject a linker changed after measurement: {message}" + ); +} + +#[test] +fn darwin_tool_root_and_target_must_share_one_parent() { + let fixture = Fixture::new(COMMIT); + let nested = fixture.base.join("other-work/target"); + let request = fixture.request_at("ts-aarch64-apple-darwin", "A", COMMIT, &nested); + let message = bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)); + assert!( + message.contains("direct children of one recipe-owned parent"), + "should require one recipe-owned parent: {message}" + ); + assert!( + !nested.exists(), + "should create nothing after the rejection" + ); + + let fixture = Fixture::new(COMMIT); + let request = fixture.request_at( + "ts-x86_64-unknown-linux-gnu", + "A", + COMMIT, + &fixture.base.join("other-work/target"), + ); + fixture + .bind_with(request, Role::Canonical, COMMIT) + .expect("should not constrain a Linux target directory"); +} + +#[test] +fn exact_darwin_plan_binds_the_linker_debug_map_and_apple_tools() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the Darwin recipe"); + let plan = prepared + .plan(&mac_paths(&fixture)) + .expect("should derive the plan in the macOS namespace"); + let base = text(&fixture.base); + let developer = text(&fixture.developer_dir()); + let clang = format!("{developer}/{TOOLCHAIN_BIN}/clang"); + let flags = [ + format!("\"--remap-path-prefix={base}/checkout-canonical=/trusted-server\""), + format!("\"--remap-path-prefix={base}/materials/vendor=/cargo-vendor\""), + format!("\"--remap-path-prefix={base}/cargo-state/cargo-home=/cargo-home\""), + format!("\"--remap-path-prefix={base}/tools=/rust-tools\""), + format!("\"--remap-path-prefix={base}/target=/cargo-target\""), + format!("\"-Clink-arg=-Wl,-oso_prefix,{base}/\""), + ]; + let remap = format!( + "target.\"aarch64-apple-darwin\".rustflags=[{}]", + flags.join(",") + ); + let linker = format!("target.\"aarch64-apple-darwin\".linker=\"{clang}\""); + let remap_at = plan + .arguments + .iter() + .position(|argument| *argument == remap) + .expect("should carry the debug-map prefix inside the one target rustflags entry"); + assert_eq!( + plan.arguments.get(remap_at + 1..remap_at + 3), + Some(["--config".to_owned(), linker].as_slice()), + "should select clang as the target linker right after the remap" + ); + let expected = [ + ("DEVELOPER_DIR", developer.clone()), + ("SDKROOT", format!("{developer}/{SDK_ROOT}")), + ("CC_aarch64_apple_darwin", clang), + ( + "AR_aarch64_apple_darwin", + format!("{developer}/{TOOLCHAIN_BIN}/ar"), + ), + ("ZERO_AR_DATE", "1".to_owned()), + ("OPENSSL_CONF", "/dev/null".to_owned()), + ]; + for (name, value) in expected { + assert_eq!( + plan.environment.get(name), + Some(&value), + "should set {name} exactly" + ); + } + let cflags = &plan.environment["CFLAGS_aarch64_apple_darwin"]; + assert!( + cflags.contains(&format!("-ffile-prefix-map={developer}=/apple-developer")) + && cflags.contains(&format!("-fdebug-prefix-map={developer}=/apple-developer")), + "should remap the developer directory for C code: {cflags}" + ); + for denied in ["CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER", "LD", "CC", "AR"] { + assert!( + !plan.environment.contains_key(denied), + "should never select a tool through {denied}" + ); + } + assert!( + !plan + .arguments + .iter() + .any(|argument| argument.contains("xcrun")), + "should never route through xcrun" + ); +} + +#[test] +fn darwin_plan_digest_binds_the_apple_tool_record() { + let fixture = Fixture::new(COMMIT); + let digest = |tools: SelectedRustTools| { + let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let prepared = fixture + .bind_parts( + request, + Role::Canonical, + COMMIT, + tools, + fixture.materials(COMMIT), + ) + .expect("should bind the Darwin recipe"); + let digest = prepared + .plan(&mac_paths(&fixture)) + .expect("should derive the plan") + .digest() + .expect("should digest the plan"); + fs::remove_dir(fixture.base.join("target")).expect("should free the target"); + for state in ["home", "cargo-home", "tmp"] { + fs::remove_dir(fixture.base.join("cargo-state").join(state)) + .expect("should free the cargo state"); + } + digest + }; + let measured = digest(fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin)); + assert_eq!( + measured, + digest(fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin)), + "should derive the same digest from the same record" + ); + let mut facts = fixture.apple_facts(); + facts.xcode_version = "16.3".to_owned(); + let relabelled = fixture + .rust_tools_for(ReleaseTarget::Aarch64AppleDarwin) + .with_apple_developer_tools( + AppleDeveloperTools::from_facts_for_tests(facts).expect("should record the facts"), + ); + assert_ne!( + measured, + digest(relabelled), + "should bind the Apple tool record into the plan digest" + ); +} + +#[test] +fn darwin_final_paths_forbid_every_root_and_the_developer_directory() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the Darwin recipe"); + let roots = [ + ( + fixture.base.join("checkout-canonical"), + "/work/source", + false, + ), + (fixture.retained(), "/work/scripts", false), + (fixture.base.join("materials"), "/work/dependencies", false), + (fixture.base.join("tools"), "/work/compiler", false), + (fixture.base.join("cargo-state"), "/work/state", true), + (fixture.base.join("target"), "/work/products", true), + (fixture.developer_dir(), "/work/developer", false), + ]; + let paths = roots + .iter() + .fold(PrefixPaths::new(), |paths, (host, execution, writable)| { + paths.with(host, execution, *writable) + }); + prepared + .check_final_paths(b"/trusted-server/src/main.rs", &paths) + .expect("should accept remapped bytes"); + let mut denied: Vec = roots + .iter() + .flat_map(|(host, execution, _)| [text(host), (*execution).to_owned()]) + .collect(); + denied.push(text(&fixture.base.join("output"))); + for prefix in denied { + let bytes = format!("/trusted-server/src/main.rs {prefix}/leaked.h"); + let error = prepared + .check_final_paths(bytes.as_bytes(), &paths) + .expect_err("should reject a bound root in final bytes"); + assert!( + format!("{error:?}").contains("unremapped path prefix"), + "should reject {prefix} by the final-path gate: {error:?}" + ); + } +} + +#[test] +fn darwin_plan_requires_the_developer_directory_in_the_namespace() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the Darwin recipe"); + let without_developer = PrefixPaths::new() + .with( + &fixture.base.join("checkout-canonical"), + "/m/checkout", + false, + ) + .with(&fixture.retained(), "/m/retained", false) + .with(&fixture.base.join("materials"), "/m/materials", false) + .with(&fixture.base.join("tools"), "/m/tools", false) + .with(&fixture.base.join("cargo-state"), "/m/state", true) + .with(&fixture.base.join("target"), "/m/target", true); + let message = plan_error(&prepared, &without_developer); + assert!( + message.contains("Apple developer directory is outside the boundary roots"), + "should name the unmapped developer directory: {message}" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs b/crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs new file mode 100644 index 000000000..9823e3bfb --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs @@ -0,0 +1,181 @@ +//! In-place Apple toolchain identity: helpers, `libLTO` and the clang +//! resource directory bound into the Apple record and its exclusions. + +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use super::fixture::*; +use super::*; +use crate::process::Cancellation; + +/// The fictional toolchain `usr` directory, with its resources and `libLTO`. +fn write_fictional_toolchain(fixture: &Fixture) -> PathBuf { + let usr = fixture + .developer_dir() + .join(TOOLCHAIN_BIN) + .parent() + .expect("should have a toolchain usr directory") + .to_owned(); + fs::create_dir_all(usr.join("lib/clang/17/include")) + .expect("should create fictional resource directory"); + fs::write( + usr.join("lib/clang/17/include/stddef.h"), + b"fictional header", + ) + .expect("should write fictional resource header"); + symlink("17", usr.join("lib/clang/17.0.0")).expect("should alias resource version"); + fs::write(usr.join("lib/libLTO.dylib"), b"fictional libLTO") + .expect("should write fictional libLTO"); + symlink("clang", usr.join("bin/clang++")).expect("should alias clang++"); + usr +} + +fn with_toolchain(apple: AppleDeveloperTools) -> Result> { + apple.with_measured_toolchain_for_tests( + Instant::now() + Duration::from_secs(60), + &Cancellation::default(), + ) +} + +fn exclusions(apple: &AppleDeveloperTools) -> Vec<&'static str> { + apple + .candidate_measurement() + .expect("should export Apple evidence") + .coverage_exclusions() + .to_vec() +} + +#[test] +fn measured_toolchain_removes_exactly_its_exclusions_and_keeps_honest_gaps() { + let fixture = Fixture::new(COMMIT); + write_fictional_toolchain(&fixture); + let lexical = fixture.apple_tools(); + let measured = with_toolchain(fixture.apple_tools()).expect("should measure toolchain"); + assert_eq!( + exclusions(&measured), + [ + "sdk_headers", + "sdk_stubs", + "sdk_frameworks", + "toolchain_libraries" + ], + "should drop only helper, libLTO and resource exclusions" + ); + let detached = AppleDeveloperTools::with_detached_sdk_for_tests( + fixture.apple_facts(), + Instant::now() + Duration::from_secs(60), + &Cancellation::default(), + ) + .expect("should stage selected SDK"); + let complete = with_toolchain(detached).expect("should measure toolchain with SDK"); + assert_eq!( + exclusions(&complete), + ["toolchain_libraries"], + "should keep unmeasured toolchain libraries excluded" + ); + let evidence = measured + .candidate_measurement() + .expect("should export measured toolchain evidence"); + assert_eq!( + evidence.liblto_sha256(), + Some(hex::encode(Sha256::digest(b"fictional libLTO")).as_str()), + "should export the libLTO digest" + ); + assert!( + evidence.clang_resource_tree_sha256().is_some() && evidence.helper_tree_sha256().is_some(), + "should export both tree digests" + ); + let lexical_evidence = lexical + .candidate_measurement() + .expect("should export lexical evidence"); + assert!( + lexical_evidence.liblto_sha256().is_none() + && lexical_evidence.clang_resource_tree_sha256().is_none() + && lexical_evidence.helper_tree_sha256().is_none(), + "should never claim toolchain coverage for lexical facts" + ); + assert_ne!( + lexical.identity_sha256().expect("should identify lexical"), + measured + .identity_sha256() + .expect("should identify measured"), + "should bind the toolchain receipt into the identity record" + ); + assert_eq!( + measured.measurement()["toolchain"]["clang_resource_version"], + "17", + "should record the selected resource version" + ); +} + +#[test] +fn identity_changes_and_verify_fails_when_any_toolchain_input_changes() { + type Mutation = fn(&std::path::Path); + let mutations: [(&str, Mutation); 4] = [ + ("resource header", |usr| { + fs::write(usr.join("lib/clang/17/include/stddef.h"), b"edited") + .expect("should edit resource header"); + }), + ("libLTO", |usr| { + fs::write(usr.join("lib/libLTO.dylib"), b"edited").expect("should edit libLTO"); + }), + ("added helper", |usr| { + fs::write(usr.join("bin/dsymutil"), b"fictional helper").expect("should add helper"); + }), + ("retargeted helper alias", |usr| { + fs::remove_file(usr.join("bin/clang++")).expect("should remove alias"); + symlink("ld", usr.join("bin/clang++")).expect("should retarget alias"); + }), + ]; + for (label, mutate) in mutations { + let fixture = Fixture::new(COMMIT); + let usr = write_fictional_toolchain(&fixture); + let original = with_toolchain(fixture.apple_tools()).expect("should measure toolchain"); + let identity = original + .identity_sha256() + .expect("should identify original"); + mutate(&usr); + assert!( + original.verify().is_err(), + "should reject changed {label} at verification" + ); + assert!( + original.candidate_measurement().is_err(), + "should reject changed {label} at evidence sealing" + ); + let remeasured = + with_toolchain(fixture.apple_tools()).expect("should remeasure changed toolchain"); + assert_ne!( + remeasured + .identity_sha256() + .expect("should identify changed"), + identity, + "should change Apple identity when {label} changes" + ); + } +} + +#[test] +fn toolchain_selection_fails_closed_without_liblto_or_with_ambiguous_resources() { + let fixture = Fixture::new(COMMIT); + let usr = write_fictional_toolchain(&fixture); + fs::remove_file(usr.join("lib/libLTO.dylib")).expect("should remove libLTO"); + assert!( + with_toolchain(fixture.apple_tools()).is_err(), + "should reject a toolchain without libLTO" + ); + let fixture = Fixture::new(COMMIT); + let usr = write_fictional_toolchain(&fixture); + fs::create_dir(usr.join("lib/clang/18")).expect("should add second resource version"); + assert!( + with_toolchain(fixture.apple_tools()).is_err(), + "should reject two clang resource versions" + ); + let fixture = Fixture::new(COMMIT); + assert!( + with_toolchain(fixture.apple_tools()).is_err(), + "should reject a toolchain without measured resources" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/fixture.rs b/crates/trusted-server-release/src/recipe/tests/fixture.rs new file mode 100644 index 000000000..a95096d69 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/fixture.rs @@ -0,0 +1,309 @@ +//! A fictional layout with a completed JS handoff and every recipe root. + +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::super::ambient::ObservedAmbient; +use super::super::paths::PrefixPaths; +use super::*; +use crate::boundary::Umask; +use crate::materials::PreparedCargoMaterials; +use crate::policy::{FIXED_ARTIFACTS, ReleaseTarget}; +use crate::preflight::audit_environment; +use crate::preflight::child::build_child_for_tests; +use crate::process::ProcessLimits; + +pub(super) const OTHER_COMMIT: &str = "fedcba9876543210fedcba9876543210fedcba98"; +pub(super) const GIT_SELECTOR: &str = "git+https://example.com/org/workspace?tag=v1"; + +pub(super) fn audited() -> AuditedEnvironment { + let snapshot: Vec<(OsString, OsString)> = [ + ("PATH", "/usr/bin"), + ("TRUSTED_SERVER__PROXY__SECRET", "fictional"), + ("LANG", "en_US.UTF-8"), + ] + .into_iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect(); + audit_environment(&snapshot).expect("should audit benign environment") +} + +pub(super) struct Fixture { + pub(super) _directory: tempfile::TempDir, + pub(super) base: PathBuf, + pub(super) completed: JsBuildCompleted, +} + +impl Fixture { + pub(super) fn new(js_commit: &str) -> Self { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize fixture"); + for relative in [ + "materials/vendor/example-1.0.0", + "cargo-state", + "output", + "checkout-canonical", + "checkout-reproduction", + "tools/bin", + "other-source", + "other-work", + ] { + fs::create_dir_all(base.join(relative)).expect("should create fixture directory"); + } + fs::write( + base.join("materials/vendor/example-1.0.0/Cargo.toml"), + b"fictional package", + ) + .expect("should write fictional vendored package"); + for tool in ["tools/bin/cargo", "tools/bin/rustc"] { + fs::write(base.join(tool), b"fictional tool").expect("should write fictional tool"); + } + for artifact in &FIXED_ARTIFACTS { + let standard = base + .join("tools/lib/rustlib") + .join(artifact.target.triple()) + .join("lib"); + fs::create_dir_all(&standard).expect("should create target standard library"); + fs::write( + standard.join("libstd-0123456789abcdef.rlib"), + b"fictional std", + ) + .expect("should write fictional libstd"); + } + write_fictional_developer_dir(&base.join(APPLE_DEVELOPER)); + Self { + _directory: directory, + base, + completed: JsBuildCompleted::fixture_for_tests(js_commit), + } + } + + /// The fictional Apple developer directory. + pub(super) fn developer_dir(&self) -> PathBuf { + self.base.join(APPLE_DEVELOPER) + } + + /// Facts measured from the fictional developer directory. + pub(super) fn apple_facts(&self) -> AppleToolFacts { + let developer_dir = self.developer_dir(); + let bin = developer_dir.join(TOOLCHAIN_BIN); + let sdk_root = developer_dir.join(SDK_ROOT); + let digest = |path: &Path| { + hex::encode(Sha256::digest( + fs::read(path).expect("should read a fictional Apple file"), + )) + }; + AppleToolFacts { + clang_sha256: digest(&bin.join("clang")), + ar_sha256: digest(&bin.join("ar")), + ld_sha256: digest(&bin.join("ld")), + sdk_settings_sha256: digest(&sdk_root.join("SDKSettings.json")), + clang: bin.join("clang"), + ar: bin.join("ar"), + ld: bin.join("ld"), + developer_dir, + sdk_root, + xcode_version: "16.4".to_owned(), + sdk_version: crate::inspect::MacosVersion::new(15, 5), + } + } + + /// The fictional Apple developer tools as an ordinary CI selection. + pub(super) fn apple_tools(&self) -> AppleDeveloperTools { + AppleDeveloperTools::from_facts_for_tests(self.apple_facts()) + .expect("should record the fictional Apple tools") + } + + pub(super) fn retained(&self) -> PathBuf { + fs::canonicalize( + self.completed + .rehash_for_handoff() + .expect("should rehash the fixture handoff"), + ) + .expect("should canonicalize the retained root") + } + + pub(super) fn request(&self, asset: &str, role: &str, commit: &str) -> RecipeRequest { + self.request_at(asset, role, commit, &self.base.join("target")) + } + + pub(super) fn request_at( + &self, + asset: &str, + role: &str, + commit: &str, + target_dir: &Path, + ) -> RecipeRequest { + let retained = self.retained(); + let js_dir = retained.join("bundles"); + let js_inventory = retained.join("tsjs-inventory.json"); + RecipeRequest::parse(&RecipeInput { + asset, + variant: "default", + role, + commit, + js_dir: &js_dir, + js_inventory: &js_inventory, + materials: &self.base.join("materials"), + cargo_state: &self.base.join("cargo-state"), + output: &self.base.join("output"), + target_dir, + }) + .expect("should parse the fixture request") + } + + pub(super) fn ambient(&self, role: Role) -> CheckedAmbient { + match role { + Role::Canonical => CheckedAmbient::check( + Role::Canonical, + &AmbientProfile::CANONICAL, + ObservedAmbient { + checkout_root: self.base.join("checkout-canonical"), + ..observed_canonical() + }, + ), + Role::Reproduction => CheckedAmbient::check( + Role::Reproduction, + &AmbientProfile::alternate_for_tests(), + ObservedAmbient { + checkout_root: self.base.join("checkout-reproduction"), + ..observed_reproduction() + }, + ), + } + .expect("should check the fixture ambient") + } + + /// Ordinary CI tools for `target`, with the fictional Apple developer + /// tools for a Darwin target. + pub(super) fn tools_for(&self, target: ReleaseTarget) -> SelectedRustTools { + let tools = self.rust_tools_for(target); + if target.is_darwin() { + tools.with_apple_developer_tools(self.apple_tools()) + } else { + tools + } + } + + /// Ordinary CI Rust tools for `target` without Apple developer tools. + pub(super) fn rust_tools_for(&self, target: ReleaseTarget) -> SelectedRustTools { + SelectedRustTools::for_tests(&self.base.join("tools"), target) + .expect("should select fixture tools") + } + + pub(super) fn materials(&self, commit: &str) -> PreparedCargoMaterials { + PreparedCargoMaterials::for_tests(&self.base.join("materials"), commit, &[GIT_SELECTOR]) + .expect("should describe fixture materials") + } + + /// Binds with explicit tools and materials. + pub(super) fn bind_parts( + &self, + request: RecipeRequest, + role: Role, + commit: &str, + tools: SelectedRustTools, + materials: PreparedCargoMaterials, + ) -> Result> { + let commit = CommitId::parse(commit).expect("should parse fixture commit"); + let child = + build_child_for_tests(&audited(), &commit, 1_700_000_000, &child_request(&request)) + .expect("should build the fixture child environment"); + PreparedRecipe::bind(BindInputs { + workspace: WorkspaceSelection::Fixture, + commit: &commit, + child, + completed: &self.completed, + tools, + materials, + ambient: self.ambient(role), + request, + }) + } + + pub(super) fn bind_with( + &self, + request: RecipeRequest, + role: Role, + commit: &str, + ) -> Result> { + let tools = self.tools_for(request.asset.artifact().target); + let materials = self.materials(commit); + self.bind_parts(request, role, commit, tools, materials) + } + + pub(super) fn bind( + &self, + asset: &str, + role: Role, + ) -> Result> { + let text = role.spec_label(); + self.bind_with(self.request(asset, text, COMMIT), role, COMMIT) + } + + /// The fake namespace mirroring the boundary's mount layout for `role`. + pub(super) fn paths(&self, role: Role) -> PrefixPaths { + let checkout = if role == Role::Canonical { + "checkout-canonical" + } else { + "checkout-reproduction" + }; + PrefixPaths::new() + .with(&self.base.join(checkout), "/boundary/immutable/0", false) + .with(&self.retained(), "/boundary/immutable/1", false) + .with(&self.base.join("materials"), "/boundary/immutable/2", false) + .with(&self.base.join("tools"), "/boundary/tool/0", false) + .with(&self.base.join("cargo-state"), "/boundary/writable/0", true) + .with(&self.base.join("target"), "/boundary/writable/1", true) + .with(&self.developer_dir(), "/boundary/system/0", false) + } +} + +/// Fictional developer directory below the fixture base. +pub(super) const APPLE_DEVELOPER: &str = "apple-developer"; +/// Default toolchain binaries below the developer directory. +pub(super) const TOOLCHAIN_BIN: &str = "Toolchains/XcodeDefault.xctoolchain/usr/bin"; +/// Fictional SDK root below the developer directory. +pub(super) const SDK_ROOT: &str = "Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk"; + +/// Writes fictional `clang`, `ar`, `ld` and SDK settings below `developer`. +fn write_fictional_developer_dir(developer: &Path) { + let bin = developer.join(TOOLCHAIN_BIN); + let sdk = developer.join(SDK_ROOT); + for directory in [&bin, &sdk] { + fs::create_dir_all(directory).expect("should create a fictional Apple directory"); + } + for tool in ["clang", "ar", "ld"] { + fs::write(bin.join(tool), format!("fictional Apple {tool}")) + .expect("should write a fictional Apple tool"); + } + fs::write(sdk.join("SDKSettings.json"), br#"{"Version":"15.5"}"#) + .expect("should write fictional SDK settings"); +} + +pub(super) fn plan_error(prepared: &PreparedRecipe, paths: &PrefixPaths) -> String { + match prepared.plan(paths) { + Ok(_) => panic!("should reject the plan"), + Err(error) => format!("{error:?}"), + } +} + +pub(super) fn bind_error(result: Result>) -> String { + match result { + Ok(_) => panic!("should reject the binding"), + Err(error) => format!("{error:?}"), + } +} + +pub(super) fn confinement_limits() -> ProcessLimits { + ProcessLimits { + wall: std::time::Duration::from_secs(60), + stdout_max: 1024, + stderr_max: 1024, + } +} + +pub(super) fn umask(mask: u32) -> Umask { + Umask::new(mask).expect("should accept mask") +} diff --git a/crates/trusted-server-release/src/recipe/tests/full.rs b/crates/trusted-server-release/src/recipe/tests/full.rs new file mode 100644 index 000000000..2a928bd97 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/full.rs @@ -0,0 +1,583 @@ +//! Explicit real Linux Cargo fixtures; never selected by a normal focused unit run. +//! +//! Both runs are ordinary CI candidate evidence (`release_qualification: +//! false`): the toolchain, container image and fixture acquisition are +//! unqualified and unauthenticated, so nothing here qualifies a release. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::super::compare::{FASTLY_ASSET, check_fastly_equality}; +use super::super::compare::{FastlyEqualityChecked, read_artifact}; +use super::super::remap::{RemapScan, scan_remapped_bytes}; +use super::super::runtime::{ + FastlyRuntimeAccepted, HealthOnlySuccess, OriginPresence, RuntimeRejection, SelectedViceroy, + accept_fastly_runtime, run_gate, +}; +use super::candidate::js_inventory_sha256; +use super::candidate::{ + CargoFixture, SCHEMA_VERSION, cargo_docker_settings, cargo_measurement, ci_build_provenance, + denial_measurement, equality_measurement, reported_errno, +}; +use super::real_fixture::{ + ATTEMPTS, DenialCase, DenialSelection, bind_denial_case, cargo_fixture, ordinary_ci_ambient, + path_text, try_prepare_vendor, +}; +use super::*; +use crate::boundary::{Boundary, DenialProof, ImageSelection, ProbeProgram}; +use crate::materials::{LockedMaterials, resolution_differences}; +use crate::preparation::tests::full::{JsFixtureRun, run_two_fresh_js_builds}; +use crate::process::{Cancellation, ProcessLimits}; + +/// A configured container boundary with its proof for this instance. +pub(super) struct Proven { + _state: tempfile::TempDir, + pub(super) boundary: Boundary, + pub(super) proof: DenialProof, +} + +/// The builder image ID a Linux container fixture names. +fn linux_image(fixture: &CargoFixture) -> &str { + fixture + .container_image_id() + .expect("should name the Linux builder image") +} + +/// Configures the exact recipe boundary in the image `image_id` and proves +/// its denial. +pub(super) fn proven_boundary( + prepared: &PreparedRecipe, + fixture: &CargoFixture, + image_id: &str, +) -> Proven { + try_proven_boundary(prepared, fixture, image_id) + .expect("should prove the exact recipe boundary") +} + +pub(super) fn try_proven_boundary( + prepared: &PreparedRecipe, + fixture: &CargoFixture, + image_id: &str, +) -> Result> { + let state = tempfile::tempdir() + .map_err(|error| recipe_error(format!("cannot create boundary state: {error}")))?; + + // These IDs only select the existing unprivileged CI worker account. + let user = unsafe { (libc::getuid(), libc::getgid()) }; + let parallelism = std::thread::available_parallelism() + .map_or(1, |count| u32::try_from(count.get()).unwrap_or(u32::MAX)); + let boundary = Boundary::linux_container( + prepared.boundary_roots()?, + cargo_docker_settings(user, parallelism), + ImageSelection::Test { + id: image_id.to_owned(), + }, + state.path(), + )?; + let probe = ProbeProgram::from_staged_release_binary(&fixture.probe())?; + let limits = ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 4096, + stderr_max: 4096, + }; + let proof = boundary.establish_denial(&probe, limits)?; + if !matches!(proof.denied_errno(), libc::EPERM | libc::EACCES) { + return Err(recipe_error( + "denial proof does not establish seccomp denial", + )); + } + Ok(Proven { + _state: state, + boundary, + proof, + }) +} + +/// Binds one Wasm denial fixture member over fresh private roots. +fn bind_wasm_denial_case(fixture: &CargoFixture, member: &str) -> DenialCase { + bind_denial_case( + fixture, + member, + DenialSelection { + asset: AssetId::denial_fixture(member).expect("should resolve the fixture member"), + tools: SelectedRustTools::for_tests(&fixture.tools(), ReleaseTarget::Wasm32Wasip1) + .expect("should select the ordinary CI Rust tools"), + target_dir: None, + }, + ) +} + +#[test] +#[ignore = "requires the explicit ordinary CI Cargo fixture and Linux Docker; run separately in producer CI"] +fn cargo_children_inherit_denial() { + let fixture = cargo_fixture(); + let mut observed = BTreeMap::new(); + let mut proof_errno = None; + for case in ATTEMPTS { + let bound = bind_wasm_denial_case(&fixture, case); + let proven = proven_boundary(&bound.prepared, &fixture, linux_image(&fixture)); + let errno = proven.proof.denied_errno(); + let error = bound + .prepared + .execute(&proven.boundary, &proven.proof) + .expect_err("should fail a build that attempts a connection"); + let text = format!("{error:?}"); + let reported = reported_errno(case, &text) + .unwrap_or_else(|| panic!("should report the {case} attempt errno: {text}")); + assert_eq!( + reported, errno, + "should deny the {case} attempt with the proven seccomp errno: {text}" + ); + assert_ne!( + reported, + libc::ECONNREFUSED, + "should deny {case} before the empty network namespace refuses it" + ); + observed.insert(case.to_owned(), reported); + proof_errno = Some(errno); + } + let bound = bind_wasm_denial_case(&fixture, "control"); + let proven = proven_boundary(&bound.prepared, &fixture, linux_image(&fixture)); + let control = bound + .prepared + .execute(&proven.boundary, &proven.proof) + .expect("should build the control through the confined recipe"); + control + .rehash_for_handoff() + .expect("should rehash the control artifact before reporting it"); + assert_eq!( + control.asset().artifact().name, + "denial-control.wasm", + "should copy the control under its asset name" + ); + let mut measurement = denial_measurement( + proof_errno.expect("should observe every attempt"), + &observed, + &control, + ); + measurement["ci_build_provenance"] = ci_build_provenance(&fixture); + let text = serde_json::to_string_pretty(&measurement).expect("should encode the measurement"); + println!("{text}"); + fs::write(fixture.root.join("cargo-denial-measurement.json"), text) + .expect("should retain the denial measurement"); +} + +/// Prepares the vendor tree from the fixture's retained originals. +/// +/// The fixture's `cargo vendor` manifests only name which locked Git packages +/// to supply closures for; the producer normalizes every inheriting manifest +/// itself from the verified tree. +fn prepare_vendor( + fixture: &CargoFixture, + locked: &LockedMaterials, + root: &Path, + deadline: Instant, + cancellation: &Cancellation, +) -> PreparedCargoMaterials { + try_prepare_vendor(fixture, locked, root, deadline, cancellation) + .expect("should vendor every locked original") +} + +/// Compares every producer-normalized manifest with the one `cargo vendor` +/// wrote, before anything is built, and requires equal resolution facts. +/// +/// This is ordinary CI differential evidence, not authority: the comparison +/// manifests are unauthenticated and never reach the vendor tree. +fn normalized_manifest_differential( + fixture: &CargoFixture, + materials: &PreparedCargoMaterials, +) -> serde_json::Value { + let mut packages = serde_json::Map::new(); + let mut differing = 0_usize; + for package in &fixture.comparison_manifests { + let directory = materials.vendor_dir().join(&package.name); + let producer = + fs::read(directory.join("Cargo.toml")).expect("should read the producer manifest"); + let cargo_vendor = fs::read(&package.cargo_vendor_manifest) + .expect("should read the cargo vendor comparison manifest"); + let build_rs_present = directory.join("build.rs").is_file(); + let differences = resolution_differences(&producer, &cargo_vendor, build_rs_present) + .expect("should compare the producer and cargo vendor manifests"); + differing += usize::from(!differences.is_empty()); + packages.insert( + package.name.clone(), + serde_json::json!({ + "producer_normalized": materials + .normalized_manifest_digests() + .contains_key(&package.name), + "differences": differences, + }), + ); + } + let measurement = serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-normalized-manifest-differential", + "compared": packages.len(), + "normalized_manifest_count": materials.normalized_manifest_digests().len(), + "derived_manifest_digests": materials.derived_manifest_digests().len(), + "differing": differing, + "packages": packages, + "authority": "evidence-only", + "release_qualification": false, + }); + println!( + "{}", + serde_json::to_string_pretty(&measurement).expect("should encode the differential") + ); + assert_eq!( + differing, 0, + "should resolve every Git manifest as cargo vendor does: {measurement}" + ); + measurement +} + +/// One confined Fastly build with the private roots its output lives in. +struct FastlyBuild { + _materials: tempfile::TempDir, + _work: tempfile::TempDir, + completed: RecipeCompleted, + /// Host roots of this build that must not appear in the final bytes. + host_roots: Vec, + /// The checkout root as the confined build saw it. + execution_checkout: String, + /// The producer-normalized versus `cargo vendor` manifest comparison. + differential: serde_json::Value, +} + +/// Builds the Fastly Wasm for `role` from the shared JS handoff. +/// +/// Each role gets its own fresh source copy (a distinct checkout root), its +/// own freshly prepared vendor tree, fresh state, output and target roots, +/// and its own boundary with its own denial proof. The target directory and +/// source copy are removed after the build; only the copied output remains. +fn build_fastly( + js: &JsFixtureRun, + fixture: &CargoFixture, + role: Role, + deadline: Instant, + cancellation: &Cancellation, +) -> FastlyBuild { + let fresh = js + .source + .materialize(deadline, cancellation) + .expect("should materialize a fresh tracked source for this role"); + let materials_directory = tempfile::tempdir().expect("should create the materials root"); + let materials_root = + fs::canonicalize(materials_directory.path()).expect("should resolve the materials root"); + let materials = prepare_vendor(fixture, &js.locked, &materials_root, deadline, cancellation); + assert_eq!( + materials.cargo_lock_sha256(), + fixture.cargo_lock_sha256, + "should vendor from the fixture's Cargo.lock" + ); + let differential = normalized_manifest_differential(fixture, &materials); + let work = tempfile::tempdir().expect("should create the build roots"); + let base = fs::canonicalize(work.path()).expect("should resolve the build roots"); + for directory in ["cargo-state", "output"] { + fs::create_dir(base.join(directory)).expect("should create a build root"); + } + let retained = fs::canonicalize( + js.completed + .rehash_for_handoff() + .expect("should rehash the JS handoff"), + ) + .expect("should resolve the retained root"); + let request = RecipeRequest::parse(&RecipeInput { + asset: FASTLY_ASSET, + variant: "default", + role: role.spec_label(), + commit: &fixture.source_commit, + js_dir: &retained.join("bundles"), + js_inventory: &retained.join("tsjs-inventory.json"), + materials: &materials_root, + cargo_state: &base.join("cargo-state"), + output: &base.join("output"), + target_dir: &base.join("target"), + }) + .expect("should accept the Fastly request"); + let prepared = PreparedRecipe::new( + &js.source, + &fresh, + &js.audited, + &js.completed, + SelectedRustTools::for_tests(&fixture.tools(), ReleaseTarget::Wasm32Wasip1) + .expect("should select the ordinary CI Rust tools"), + materials, + ordinary_ci_ambient(role, fresh.root()), + request, + ) + .expect("should bind the real source, handoff, tools and materials"); + let proven = proven_boundary(&prepared, fixture, linux_image(fixture)); + let execution_checkout = path_text( + &proven + .boundary + .execution_path(fresh.root()) + .expect("should translate the checkout root"), + ); + let host_roots = [ + fresh.root(), + materials_root.as_path(), + base.as_path(), + retained.as_path(), + ] + .map(path_text) + .to_vec(); + let completed = prepared + .execute(&proven.boundary, &proven.proof) + .unwrap_or_else(|error| { + panic!( + "should build the role {role:?} Fastly Wasm through the confined recipe: {error:?}" + ) + }); + completed + .rehash_for_handoff() + .expect("should rehash the artifact before reporting it"); + assert!( + completed.derived_manifest_digests().is_empty() + && !completed.normalized_manifest_digests().is_empty(), + "should build from producer-normalized Git manifests and no caller-derived one" + ); + println!( + "{}", + serde_json::to_string_pretty(&cargo_measurement(&completed)) + .expect("should encode the build measurement") + ); + fs::remove_dir_all(base.join("target")).expect("should remove the spent target directory"); + fresh.close().expect("should remove the fresh source copy"); + completed + .denial_receipt() + .expect("should retain measured socket observations after working-root cleanup"); + completed + .recipe_definition() + .expect("should retain the approved recipe definition after working-root cleanup"); + FastlyBuild { + _materials: materials_directory, + _work: work, + completed, + host_roots, + execution_checkout, + differential, + } +} + +/// Scans the final bytes for every host root and the container prefix, and +/// requires at least one fixed remapped prefix. +fn scan_final_bytes(fixture: &CargoFixture, builds: [&FastlyBuild; 2]) -> RemapScan { + let mut forbidden: Vec = builds + .iter() + .flat_map(|build| build.host_roots.iter().cloned()) + .collect(); + forbidden.push(path_text(&fixture.root)); + if let Some(runner_temp) = std::env::var_os("RUNNER_TEMP") { + forbidden.push( + runner_temp + .into_string() + .expect("should name RUNNER_TEMP in UTF-8"), + ); + } + let forbidden: Vec<&str> = forbidden.iter().map(String::as_str).collect(); + let bytes = fs::read(builds[0].completed.artifact().path()) + .expect("should read the compared final bytes"); + scan_remapped_bytes(&bytes, &forbidden) + .unwrap_or_else(|error| panic!("should prove remapping in the final bytes: {error:?}")) +} + +#[test] +#[ignore = "requires explicit JS and Cargo ordinary CI fixtures, Linux Docker and the pinned Viceroy; run separately in producer CI"] +fn fastly_canonical_and_reproduction_builds_are_byte_identical() { + let fixture = cargo_fixture(); + let js = run_two_fresh_js_builds(); + assert_eq!( + js.source.commit().as_str(), + fixture.source_commit, + "should build the Cargo fixture's source commit" + ); + println!( + "{}", + serde_json::to_string_pretty(&js.measurement).expect("should encode the JS measurement") + ); + let cancellation = Cancellation::default(); + let deadline = Instant::now() + Duration::from_secs(40 * 60); + let canonical = build_fastly(&js, &fixture, Role::Canonical, deadline, &cancellation); + let reproduction = build_fastly(&js, &fixture, Role::Reproduction, deadline, &cancellation); + let checked = check_fastly_equality(&canonical.completed, &reproduction.completed) + .unwrap_or_else(|error| { + panic!( + "should build byte-identical canonical and reproduction Fastly outputs: {error:?}" + ) + }); + let scan = scan_final_bytes(&fixture, [&canonical, &reproduction]); + let mut measurement = + equality_measurement(&canonical.completed, &reproduction.completed, &checked); + for (role, build) in [("canonical", &canonical), ("reproduction", &reproduction)] { + // Container paths are identical, so checkout variation is host-only on Linux. + measurement["builds"][role]["ambient"]["execution_checkout_root"] = + serde_json::json!(build.execution_checkout); + } + let differential = serde_json::json!({ + "canonical": canonical.differential, + "reproduction": reproduction.differential, + }); + fs::write( + fixture + .root + .join("normalized-manifest-differential-measurement.json"), + serde_json::to_string_pretty(&differential).expect("should encode the differential"), + ) + .expect("should retain the normalized manifest differential"); + measurement["remap_scan"] = serde_json::json!({ + "forbidden": ["/boundary/", "host build roots", "fixture root", "RUNNER_TEMP"], + "found": scan.found(), + }); + measurement["js_inventory_sha256"] = + serde_json::json!(js_inventory_sha256(&canonical.completed)); + measurement["ci_build_provenance"] = ci_build_provenance(&fixture); + retain_equality_measurement(&fixture, &measurement); + let [canonical_runtime, reproduction_runtime] = run_runtime_gate(&RuntimeGateInputs { + fixture: &fixture, + source: &js.source, + canonical: &canonical, + reproduction: &reproduction, + checked: &checked, + }); + assert!( + canonical_runtime + .diagnostic_for(&reproduction.completed) + .is_err(), + "should reject attaching canonical acceptance to the equal-byte reproduction role" + ); + assert!( + reproduction_runtime + .diagnostic_for(&canonical.completed) + .is_err(), + "should reject attaching reproduction acceptance to the equal-byte canonical role" + ); +} + +/// Prints and retains `measurement` as the canonical/reproduction equality +/// measurement. +fn retain_equality_measurement(fixture: &CargoFixture, measurement: &serde_json::Value) { + let text = serde_json::to_string_pretty(measurement).expect("should encode the measurement"); + println!("{text}"); + fs::write( + fixture + .root + .join("cargo-canonical-reproduction-equality-measurement.json"), + text, + ) + .expect("should retain the canonical/reproduction equality measurement"); +} + +/// Everything the chained runtime gate needs from the equality run. +struct RuntimeGateInputs<'a> { + fixture: &'a CargoFixture, + source: &'a ApprovedSource, + canonical: &'a FastlyBuild, + reproduction: &'a FastlyBuild, + checked: &'a FastlyEqualityChecked, +} + +/// Selects the pinned Viceroy named by `TS_RELEASE_VICEROY`. +fn pinned_viceroy() -> SelectedViceroy { + let path = std::env::var_os("TS_RELEASE_VICEROY") + .map(PathBuf::from) + .expect("should explicitly supply the pinned Viceroy executable"); + SelectedViceroy::select(&path).expect("should select the pinned Viceroy 0.21.0") +} + +/// The ordinary CI measurement of one accepted runtime gate run. +fn accepted_measurement(accepted: &FastlyRuntimeAccepted) -> serde_json::Value { + let source_commit = accepted.source_commit().as_str(); + let accepted = accepted.observed(); + let publisher = accepted.publisher(); + let output = accepted.viceroy_output(); + serde_json::json!({ + "source_commit": source_commit, + "viceroy_output": { + "stdout_sha256": output.stdout_sha256, + "stderr_sha256": output.stderr_sha256, + "stderr_tail": output.stderr_tail, + }, + "artifact": { + "sha256": accepted.artifact_sha256(), + "size": accepted.artifact_size(), + }, + "fixture_sha256": accepted.fixture_sha256(), + "config_sha256": accepted.config_sha256(), + "health_status": accepted.health_status(), + "publisher": { + "status": publisher.status, + "body_size": publisher.body_size, + "body_sha256": publisher.body_sha256, + "script_src": publisher.script_src, + }, + "origin_requests": accepted.origin_requests(), + "startup_seconds": accepted.startup().as_secs_f64(), + "viceroy_outcome": accepted.viceroy_outcome().to_string(), + }) +} + +/// Runs the final-byte runtime gate on both equal outputs, then proves that +/// a run with a closed origin is rejected although its health check passes. +fn run_runtime_gate(inputs: &RuntimeGateInputs<'_>) -> [FastlyRuntimeAccepted; 2] { + let RuntimeGateInputs { + fixture, + source, + canonical, + reproduction, + checked, + } = *inputs; + let viceroy = pinned_viceroy(); + let mut builds = serde_json::Map::new(); + let mut accepted_runs = Vec::new(); + for (role, build) in [("canonical", canonical), ("reproduction", reproduction)] { + let accepted = accept_fastly_runtime(source, &build.completed, checked, &viceroy) + .unwrap_or_else(|error| { + panic!("should accept the {role} final bytes through Viceroy: {error:?}") + }); + assert_eq!( + accepted.observed().artifact_sha256(), + checked.sha256(), + "should execute the exact accepted {role} bytes" + ); + builds.insert(role.to_owned(), accepted_measurement(&accepted)); + accepted_runs.push(accepted); + } + let bytes = read_artifact(&canonical.completed).expect("should reread the canonical bytes"); + let rejected = run_gate(&bytes, &viceroy, OriginPresence::Closed) + .expect_err("should reject a run whose origin is unreachable"); + assert!( + rejected.downcast_ref::().is_some(), + "should reject a health-only success: {rejected:?}" + ); + canonical + .completed + .rehash_for_handoff() + .expect("should leave the canonical artifact unchanged"); + let measurement = serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-fastly-runtime-gate", + "asset": checked.asset().artifact().name, + "viceroy": { + "version": viceroy.version(), + "sha256": viceroy.sha256(), + }, + "builds": builds, + "health_only_rejection": rejected + .downcast_ref::() + .map(ToString::to_string), + "release_qualification": false, + }); + let text = serde_json::to_string_pretty(&measurement).expect("should encode the measurement"); + println!("{text}"); + fs::write( + fixture.root.join("fastly-runtime-gate-measurement.json"), + text, + ) + .expect("should retain the runtime gate measurement"); + accepted_runs + .try_into() + .expect("should retain exactly the canonical and reproduction runtime owners") +} diff --git a/crates/trusted-server-release/src/recipe/tests/full_macos.rs b/crates/trusted-server-release/src/recipe/tests/full_macos.rs new file mode 100644 index 000000000..d12198e2d --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/full_macos.rs @@ -0,0 +1,206 @@ +//! Explicit real macOS Cargo fixtures; never selected by a normal focused +//! unit run. +//! +//! Both runs are ordinary CI candidate evidence (`release_qualification: +//! false`): the Xcode, toolchain and fixture acquisition are unqualified and +//! unauthenticated, and the unqualified `sandbox-exec` boundary only reaps +//! descendants after each run (`post-run-reaped`), which only the test-only +//! ordinary CI tool selection admits. Nothing here qualifies a release. +//! +//! A Darwin build's Rust tool root and target directory must be direct +//! children of one parent, which the linker strips from every debug-map +//! object path. These fixtures use the Cargo fixture root as that parent: +//! the target directory is `/target`, beside `/tools`, so +//! builds run one at a time and remove it afterwards. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use serde_json::json; + +use super::candidate::{ + CargoFixture, FixtureHost, ci_build_provenance, denial_measurement, reported_errno, +}; +use super::macos_candidate::darwin_cli_asset; +use super::real_fixture::{ATTEMPTS, DenialCase, DenialSelection, bind_denial_case, cargo_fixture}; +use super::*; +use crate::boundary::{Boundary, DenialProof, DescendantCleanup, ProbeProgram}; +use crate::process::{Cancellation, ProcessLimits}; + +/// Reads the ordinary CI Cargo fixture and requires its macOS kind. +pub(super) fn macos_cargo_fixture() -> CargoFixture { + let fixture = cargo_fixture(); + assert_eq!( + fixture.host, + FixtureHost::MacosSandbox, + "should run on a macOS Cargo fixture that names no image" + ); + fixture +} + +/// The Darwin `ts` asset of the running producer's architecture, which must +/// build for the `HOST_TARGET` the CI job asserted. +pub(super) fn job_host_asset() -> AssetId { + let host_target = + std::env::var("HOST_TARGET").expect("should name the HOST_TARGET the job asserted"); + darwin_cli_asset(std::env::consts::ARCH, &host_target) + .expect("should select the Darwin ts asset of the host architecture") +} + +/// The target directory beside the fixture's tool root. +pub(super) fn darwin_target_dir(fixture: &CargoFixture) -> PathBuf { + fixture.root.join("target") +} + +/// Removes a spent Darwin target directory; an absent one is already gone. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the directory cannot be removed. +pub(super) fn remove_target_dir(target: &Path) -> Result<(), Report> { + match fs::remove_dir_all(target) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(recipe_error(format!( + "cannot remove the Darwin target directory: {error}" + ))), + } +} + +/// A configured macOS sandbox boundary with its proof for this instance. +pub(super) struct ProvenSandbox { + pub(super) boundary: Boundary, + pub(super) proof: DenialProof, +} + +/// Configures the exact recipe boundary as an unqualified macOS sandbox and +/// proves its denial with the staged producer probe. +/// +/// # Errors +/// +/// Returns an error when the boundary cannot be configured or proven, the +/// proof does not report the sandbox `EPERM`, or the boundary does not +/// report post-run-reaped descendant cleanup. +pub(super) fn try_proven_sandbox( + prepared: &PreparedRecipe, + fixture: &CargoFixture, +) -> Result> { + let boundary = Boundary::mac_sandbox_for_tests(prepared.boundary_roots()?)?; + if boundary.descendant_cleanup() != DescendantCleanup::ReapedAfterRun { + return Err(recipe_error( + "the macOS sandbox does not report post-run-reaped cleanup", + )); + } + let probe = ProbeProgram::from_staged_release_binary(&fixture.probe())?; + let limits = ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 4096, + stderr_max: 4096, + }; + let proof = boundary.establish_denial(&probe, limits)?; + if proof.denied_errno() != libc::EPERM { + return Err(recipe_error( + "denial proof does not establish the sandbox EPERM denial", + )); + } + Ok(ProvenSandbox { boundary, proof }) +} + +/// Binds one Darwin denial fixture member for `target` with the Xcode tools. +fn bind_darwin_denial_case( + fixture: &CargoFixture, + member: &str, + target: ReleaseTarget, + apple: &AppleDeveloperTools, +) -> DenialCase { + bind_denial_case( + fixture, + member, + DenialSelection { + asset: AssetId::darwin_denial_fixture(member, target) + .expect("should resolve the Darwin fixture member"), + tools: SelectedRustTools::for_tests(&fixture.tools(), target) + .expect("should select the ordinary CI Rust tools") + .with_apple_developer_tools(apple.clone()), + target_dir: Some(darwin_target_dir(fixture)), + }, + ) +} + +#[test] +#[ignore = "requires the explicit ordinary CI macOS Cargo fixture, Xcode and the macOS sandbox; run separately in producer CI"] +fn cargo_children_inherit_denial_macos() { + let fixture = macos_cargo_fixture(); + let target = job_host_asset().artifact().target; + let cancellation = Cancellation::default(); + let apple = AppleDeveloperTools::for_ordinary_ci( + Instant::now() + Duration::from_secs(60 * 60), + &cancellation, + ) + .expect("should measure the selected Xcode"); + let target_dir = darwin_target_dir(&fixture); + let mut observed = BTreeMap::new(); + for case in ATTEMPTS { + let bound = bind_darwin_denial_case(&fixture, case, target, &apple); + let proven = try_proven_sandbox(&bound.prepared, &fixture) + .unwrap_or_else(|error| panic!("should prove the {case} sandbox: {error:?}")); + let error = bound + .prepared + .execute(&proven.boundary, &proven.proof) + .expect_err("should fail a build that attempts a connection"); + remove_target_dir(&target_dir).expect("should remove the failed case's target"); + let text = format!("{error:?}"); + let reported = reported_errno(case, &text) + .unwrap_or_else(|| panic!("should report the {case} attempt errno: {text}")); + assert_eq!( + reported, + proven.proof.denied_errno(), + "should deny the {case} attempt with the proven sandbox errno: {text}" + ); + assert_eq!( + reported, + libc::EPERM, + "should deny {case} with the sandbox EPERM: {text}" + ); + assert_ne!( + reported, + libc::ECONNREFUSED, + "should deny {case} before any listener refuses it" + ); + observed.insert(case.to_owned(), reported); + } + let bound = bind_darwin_denial_case(&fixture, "control", target, &apple); + let proven = try_proven_sandbox(&bound.prepared, &fixture) + .unwrap_or_else(|error| panic!("should prove the control sandbox: {error:?}")); + let control = bound.prepared.execute(&proven.boundary, &proven.proof); + remove_target_dir(&target_dir).expect("should remove the control target"); + let control = control.unwrap_or_else(|error| { + panic!("should build and accept the Darwin control through the confined recipe: {error:?}") + }); + control + .rehash_for_handoff() + .expect("should rehash the control artifact before reporting it"); + assert_eq!( + control.asset().artifact().name, + format!("denial-control-{}", target.triple()), + "should copy the control under its Darwin row name" + ); + assert_eq!( + control.descendant_cleanup(), + DescendantCleanup::ReapedAfterRun, + "should record the post-run-reaped cleanup" + ); + let mut measurement = denial_measurement(libc::EPERM, &observed, &control); + measurement["kind"] = json!("ordinary-ci-macos-cargo-denial"); + measurement["target"] = json!(target.triple()); + measurement["boundary"] = json!("macos-sandbox-exec"); + measurement["descendant_cleanup"] = json!(control.descendant_cleanup().to_string()); + measurement["apple_developer_tools"] = apple.measurement(); + measurement["ci_build_provenance"] = ci_build_provenance(&fixture); + let text = serde_json::to_string_pretty(&measurement).expect("should encode the measurement"); + println!("{text}"); + fs::write(fixture.root.join("cargo-denial-measurement.json"), text) + .expect("should retain the macOS denial measurement"); +} diff --git a/crates/trusted-server-release/src/recipe/tests/macos_candidate.rs b/crates/trusted-server-release/src/recipe/tests/macos_candidate.rs new file mode 100644 index 000000000..c2bc532d3 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/macos_candidate.rs @@ -0,0 +1,278 @@ +//! Ordinary CI macOS CLI candidate support: the host asset selection and the +//! measurement the real macOS fixture prints. +//! +//! Pure and compiled everywhere. Nothing here is release qualification: the +//! Xcode, toolchain and adopted JS handoff are unqualified and +//! unauthenticated, descendants are only reaped after each run, and the +//! measurement says so. + +use serde_json::{Value, json}; + +use super::super::startup::native::HostIdentity; +use super::candidate::{SCHEMA_VERSION, cargo_measurement}; +use super::cli_candidate::{ReproductionOutcome, reproduction_measurement}; +use super::complete::built; +use super::complete_darwin::darwin_executable; +use super::fixture::Fixture; +use super::*; +use crate::inspect::{MachoCpu, MachoFacts, MachoPolicy, inspect_macho}; + +/// File the macOS CLI candidate measurement is written to. +pub(super) const MEASUREMENT_FILE: &str = "macos-cli-candidate-measurement.json"; +/// Kind of the macOS CLI candidate measurement. +const MEASUREMENT_KIND: &str = "ordinary-ci-macos-cli-candidate"; +/// The fixed environment names of the native startup child. +const STARTUP_ENVIRONMENT: [&str; 4] = ["HOME", "LC_ALL", "PATH", "TMPDIR"]; + +/// Selects the Darwin `ts` asset of the running `architecture` and requires +/// it to build for `host_target`, the triple the CI job asserted. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for an architecture with no Darwin `ts` +/// asset, or an asset whose target is not `host_target` (such as an arm64 +/// producer on a job that asserted an Intel host). +pub(super) fn darwin_cli_asset( + architecture: &str, + host_target: &str, +) -> Result> { + let name = match architecture { + "aarch64" => "ts-aarch64-apple-darwin", + "x86_64" => "ts-x86_64-apple-darwin", + other => { + return Err(recipe_error(format!( + "no Darwin ts asset for architecture {other:?}" + ))); + } + }; + let asset = AssetId::parse(name)?; + if asset.artifact().target.triple() != host_target { + return Err(recipe_error(format!( + "Darwin ts asset {name} does not build for the host target {host_target:?}" + ))); + } + Ok(asset) +} + +/// The Mach-O facts the measurement records. +fn macho_measurement(facts: &MachoFacts) -> Value { + json!({ + "sha256": facts.sha256(), + "cpu": facts.cpu().to_string(), + "minos": facts.minos().to_string(), + "sdk": facts.sdk().to_string(), + "dylibs": facts.dylibs(), + "uuid": hex::encode(facts.uuid()), + "symbol_count": facts.symbol_count(), + "debug_map_objects": facts.debug_map_objects(), + "signature": facts.signature().to_string(), + }) +} + +/// The host identity the measurement records. +fn host_measurement(host: &HostIdentity) -> Value { + json!({ + "product_version": host.product_version, + "architecture": host.architecture, + "proc_translated": host.proc_translated, + "hw_optional_arm64": host.hw_optional_arm64, + }) +} + +/// Everything the macOS CLI candidate measurement states. +pub(super) struct MacosCliCandidateFacts<'a> { + pub(super) canonical: &'a RecipeCompleted, + pub(super) macho: &'a MachoFacts, + pub(super) apple: &'a AppleDeveloperTools, + pub(super) host: &'a HostIdentity, + /// The adoption label of the Linux job's JS handoff. + pub(super) js_handoff: &'a Value, + pub(super) remap_found: &'a [&'static str], + pub(super) operator_values_removed: &'a [String], + /// The accepted `ts --version` output, once the native startup ran. + pub(super) startup_stdout: Option<&'a str>, + pub(super) reproduction: ReproductionOutcome<'a>, +} + +/// The ordinary CI measurement of the macOS CLI candidate. +pub(super) fn macos_cli_candidate_measurement(facts: &MacosCliCandidateFacts<'_>) -> Value { + let startup = facts.startup_stdout.map_or_else( + || json!({"status": "not-run"}), + |stdout| { + json!({ + "status": "accepted", + "exit_code": 0, + "stdout": stdout, + "environment": STARTUP_ENVIRONMENT, + }) + }, + ); + json!({ + "schema_version": SCHEMA_VERSION, + "kind": MEASUREMENT_KIND, + "asset": facts.canonical.asset().artifact().name, + "source_commit": facts.canonical.commit().as_str(), + "host": host_measurement(facts.host), + "apple_developer_tools": facts.apple.measurement(), + "js_handoff": facts.js_handoff["js_handoff"], + "boundary": "macos-sandbox-exec", + "descendant_cleanup": facts.canonical.descendant_cleanup().to_string(), + "canonical": cargo_measurement(facts.canonical), + "macho": macho_measurement(facts.macho), + "remap_scan": { + "forbidden": ["host build roots", "fixture root", "RUNNER_TEMP"], + "found": facts.remap_found, + }, + "operator_values": { + "removed_by_preflight": facts.operator_values_removed, + "found_in_final_bytes": 0, + }, + "startup": startup, + "reproduction": reproduction_measurement(&facts.reproduction), + "release_qualification": false, + }) +} + +#[test] +fn darwin_cli_asset_follows_the_host_architecture_and_target() { + for (architecture, triple) in [ + ("aarch64", "aarch64-apple-darwin"), + ("x86_64", "x86_64-apple-darwin"), + ] { + let asset = darwin_cli_asset(architecture, triple).expect("should select the host asset"); + assert_eq!( + asset.artifact().target.triple(), + triple, + "should build the asset for the host target" + ); + assert_eq!(asset.artifact().bin, "ts", "should select the ts binary"); + } + for (architecture, triple) in [ + ("aarch64", "x86_64-apple-darwin"), + ("x86_64", "aarch64-apple-darwin"), + ("x86_64", "x86_64-unknown-linux-gnu"), + ("riscv64", "riscv64-example-darwin"), + ] { + assert!( + darwin_cli_asset(architecture, triple).is_err(), + "should reject {architecture} on a job asserting {triple}" + ); + } +} + +#[test] +fn macos_measurement_states_unqualified_reaped_unauthenticated_evidence() { + let bytes = darwin_executable(); + let mut build = built("ts-aarch64-apple-darwin", Role::Canonical, &bytes); + let stdout = build.stdout(|_| {}); + let canonical = build + .finish(stdout) + .expect("should complete the Darwin fixture"); + let macho = inspect_macho(&bytes, &MachoPolicy::macos(MachoCpu::Arm64)) + .expect("should inspect the canonical bytes"); + let apple = Fixture::new(COMMIT).apple_tools(); + let host = HostIdentity { + product_version: "15.5".to_owned(), + architecture: "aarch64".to_owned(), + proc_translated: Some(0), + hw_optional_arm64: Some(1), + }; + let js_handoff = json!({ + "js_handoff": { + "origin": "linux-ordinary-ci-artifact", + "authenticated": false, + "source_commit": COMMIT, + } + }); + let removed = vec!["TRUSTED_SERVER__PUBLISHER__DOMAIN".to_owned()]; + + let measurement = macos_cli_candidate_measurement(&MacosCliCandidateFacts { + canonical: &canonical, + macho: &macho, + apple: &apple, + host: &host, + js_handoff: &js_handoff, + remap_found: &["/trusted-server"], + operator_values_removed: &removed, + startup_stdout: Some("ts example\n"), + reproduction: ReproductionOutcome::BuildFailed("reproduction unavailable".to_owned()), + }); + + assert_eq!(measurement["kind"], "ordinary-ci-macos-cli-candidate"); + assert_eq!( + measurement["release_qualification"], false, + "should never claim release qualification" + ); + assert_eq!( + measurement["descendant_cleanup"], "post-run-reaped", + "should state the weaker macOS cleanup" + ); + assert_eq!(measurement["asset"], "ts-aarch64-apple-darwin"); + assert_eq!(measurement["js_handoff"]["authenticated"], false); + assert_eq!( + measurement["js_handoff"]["origin"], + "linux-ordinary-ci-artifact" + ); + assert_eq!(measurement["host"]["architecture"], "aarch64"); + assert_eq!(measurement["host"]["proc_translated"], 0); + assert_eq!( + measurement["apple_developer_tools"]["xcode_version"], + apple.measurement()["xcode_version"], + "should record the measured Xcode" + ); + assert_eq!( + measurement["apple_developer_tools"]["clang_sha256"], + apple.measurement()["clang_sha256"], + "should record the tool hashes" + ); + assert_eq!(measurement["macho"]["signature"], "linker-signed-ad-hoc"); + assert_eq!( + measurement["canonical"]["artifact"]["sha256"], + canonical.artifact().sha256(), + "should record the accepted canonical artifact" + ); + assert_eq!(measurement["startup"]["status"], "accepted"); + assert_eq!(measurement["startup"]["stdout"], "ts example\n"); + assert_eq!( + measurement["reproduction"]["status"], "build-failed", + "should record an unavailable reproduction as a measurement" + ); + + let pending = macos_cli_candidate_measurement(&MacosCliCandidateFacts { + startup_stdout: None, + reproduction: ReproductionOutcome::NotAttempted, + canonical: &canonical, + macho: &macho, + apple: &apple, + host: &host, + js_handoff: &js_handoff, + remap_found: &[], + operator_values_removed: &removed, + }); + assert_eq!(pending["startup"]["status"], "not-run"); + assert_eq!(pending["reproduction"]["status"], "not-attempted"); + + let deferred = macos_cli_candidate_measurement(&MacosCliCandidateFacts { + startup_stdout: Some("ts example\n"), + reproduction: ReproductionOutcome::Deferred, + canonical: &canonical, + macho: &macho, + apple: &apple, + host: &host, + js_handoff: &js_handoff, + remap_found: &[], + operator_values_removed: &removed, + }); + assert_eq!( + deferred["reproduction"]["status"], "deferred-to-scheduled-run", + "should record a deferred reproduction explicitly" + ); + assert_eq!( + deferred["reproduction"]["gate"], "measurement-only", + "should keep a deferred reproduction a measurement" + ); + assert_eq!( + deferred["release_qualification"], false, + "should never claim release qualification when deferring" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/macos_cli.rs b/crates/trusted-server-release/src/recipe/tests/macos_cli.rs new file mode 100644 index 000000000..90084d52e --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/macos_cli.rs @@ -0,0 +1,370 @@ +//! Explicit real macOS CLI candidate fixture; never selected by a normal +//! focused unit run. +//! +//! The run is ordinary CI candidate evidence (`release_qualification: +//! false`): the Xcode, toolchain, fixture acquisition and the JS handoff +//! adopted from the Linux job are unqualified and unauthenticated, and the +//! macOS sandbox only reaps descendants after each run. The canonical build +//! is gated (Mach-O acceptance in completion, the debug map and measured +//! SDK, the remap and final-path scans, planted operator values, native +//! startup); the reproduction build is a recorded measurement only, and +//! runs only when `TS_RELEASE_NATIVE_REPRODUCTION=measure` (otherwise the +//! measurement records it as deferred to the scheduled run). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::super::compare::read_artifact; +use super::super::native::{check_operator_values_absent, measure_native_repeatability}; +use super::super::remap::scan_remapped_bytes; +use super::super::startup::native::{NativeStartupLayout, measure_host_identity}; +use super::super::startup::{NativeStartupAccepted, StartupCopy}; +use super::candidate::js_inventory_sha256; +use super::candidate::{CargoFixture, ci_build_provenance}; +use super::cli_candidate::{ + NativeReproduction, OPERATOR_SENTINELS, ReproductionOutcome, check_build_budget, + finish_cleanup, native_reproduction_from_env, reproduction_result, +}; +use super::full_macos::{ + darwin_target_dir, job_host_asset, macos_cargo_fixture, remove_target_dir, try_proven_sandbox, +}; +use super::macos_candidate::{ + MEASUREMENT_FILE, MacosCliCandidateFacts, macos_cli_candidate_measurement, +}; +use super::real_fixture::{ + environment_with_operator_values, minimal_environment, path_text, try_ordinary_ci_ambient, + try_prepare_vendor, +}; +use super::*; +use crate::boundary::DescendantCleanup; +use crate::inspect::{MachoCpu, MachoPolicy, inspect_macho}; +use crate::materials::LockedMaterials; +use crate::preflight::AuditedEnvironment; +use crate::process::Cancellation; +use crate::source::authenticate; + +/// Everything shared by both macOS CLI builds. +struct MacosInputs<'a> { + fixture: &'a CargoFixture, + source: &'a ApprovedSource, + locked: &'a LockedMaterials, + js: &'a JsBuildCompleted, + apple: &'a AppleDeveloperTools, + asset: AssetId, + audited: &'a AuditedEnvironment, + deadline: Instant, + cancellation: &'a Cancellation, +} + +/// One confined macOS CLI build with the private roots its output lives in. +struct MacosBuild { + _materials: tempfile::TempDir, + _work: tempfile::TempDir, + completed: RecipeCompleted, + /// Host roots of this build that must not appear in the final bytes. + host_roots: Vec, +} + +fn fixture_io(error: std::io::Error) -> Report { + Report::new(ReleaseError::Recipe) + .attach("macOS CLI fixture filesystem operation failed") + .attach(error) +} + +fn close_directory(directory: Option) -> Result<(), Report> { + directory.map_or(Ok(()), |directory| directory.close().map_err(fixture_io)) +} + +/// Builds the Darwin `ts` for `role` from the adopted JS handoff. +/// +/// Each role gets its own fresh source copy, vendor tree, state and output +/// roots and its own proven sandbox; both share the target directory beside +/// the tool root, which is removed after each build. Setup, execution and +/// cleanup failures are returned so reproduction stays optional. +fn build_macos_cli( + inputs: &MacosInputs<'_>, + role: Role, +) -> Result> { + check_build_budget(inputs.deadline, inputs.cancellation)?; + let target_dir = darwin_target_dir(inputs.fixture); + let fresh = inputs + .source + .materialize(inputs.deadline, inputs.cancellation)?; + let mut materials_directory = None; + let mut work = None; + let built = (|| { + let materials_owner = tempfile::tempdir().map_err(fixture_io)?; + let materials_root = fs::canonicalize(materials_owner.path()).map_err(fixture_io)?; + materials_directory = Some(materials_owner); + let materials = try_prepare_vendor( + inputs.fixture, + inputs.locked, + &materials_root, + inputs.deadline, + inputs.cancellation, + )?; + let work_owner = tempfile::tempdir().map_err(fixture_io)?; + let base = fs::canonicalize(work_owner.path()).map_err(fixture_io)?; + work = Some(work_owner); + for directory in ["cargo-state", "output"] { + fs::create_dir(base.join(directory)).map_err(fixture_io)?; + } + let retained = fs::canonicalize(inputs.js.rehash_for_handoff()?).map_err(fixture_io)?; + let artifact = inputs.asset.artifact(); + let request = RecipeRequest::parse(&RecipeInput { + asset: artifact.name, + variant: "default", + role: role.spec_label(), + commit: inputs.source.commit().as_str(), + js_dir: &retained.join("bundles"), + js_inventory: &retained.join("tsjs-inventory.json"), + materials: &materials_root, + cargo_state: &base.join("cargo-state"), + output: &base.join("output"), + target_dir: &target_dir, + })?; + let tools = SelectedRustTools::for_tests(&inputs.fixture.tools(), artifact.target)? + .with_apple_developer_tools(inputs.apple.clone()); + let prepared = PreparedRecipe::new( + inputs.source, + &fresh, + inputs.audited, + inputs.js, + tools, + materials, + try_ordinary_ci_ambient(role, fresh.root())?, + request, + )?; + let proven = try_proven_sandbox(&prepared, inputs.fixture)?; + let host_roots = [ + fresh.root(), + materials_root.as_path(), + base.as_path(), + retained.as_path(), + target_dir.as_path(), + ] + .map(path_text) + .to_vec(); + let completed = prepared.execute(&proven.boundary, &proven.proof)?; + completed.rehash_for_handoff()?; + Ok((completed, host_roots)) + })(); + // Attempt every cleanup even when setup, execution, or another cleanup fails. + let built = finish_cleanup(built, fresh.close()); + let built = finish_cleanup(built, remove_target_dir(&target_dir)); + let built = built.and_then(|(completed, host_roots)| { + completed.denial_receipt()?; + completed.recipe_definition()?; + Ok((completed, host_roots)) + }); + match built { + Ok((completed, host_roots)) => Ok(MacosBuild { + _materials: materials_directory + .ok_or_else(|| recipe_error("missing materials root"))?, + _work: work.ok_or_else(|| recipe_error("missing work root"))?, + completed, + host_roots, + }), + Err(error) => { + let result = finish_cleanup::<()>(Err(error), close_directory(materials_directory)); + Err(finish_cleanup(result, close_directory(work)) + .expect_err("should retain the failed build and cleanup errors")) + } + } +} + +/// Scans the final bytes for every host and fixture root and `RUNNER_TEMP`, +/// and returns the fixed remapped prefixes found. +fn scan_final_bytes(bytes: &[u8], build: &MacosBuild, fixture_root: &Path) -> Vec<&'static str> { + let mut forbidden = build.host_roots.clone(); + forbidden.push(path_text(fixture_root)); + if let Some(runner_temp) = std::env::var_os("RUNNER_TEMP") { + forbidden.push( + runner_temp + .into_string() + .expect("should name RUNNER_TEMP in UTF-8"), + ); + } + let forbidden: Vec<&str> = forbidden.iter().map(String::as_str).collect(); + scan_remapped_bytes(bytes, &forbidden) + .unwrap_or_else(|error| panic!("should prove remapping in the final bytes: {error:?}")) + .found() + .to_vec() +} + +/// Starts a read-only copy of the exact final bytes natively as +/// `ts --version`, outside every build root, and removes the copy. +fn start_natively(build: &MacosBuild) -> NativeStartupAccepted { + let scratch = tempfile::tempdir().expect("should create startup scratch space"); + let base = fs::canonicalize(scratch.path()).expect("should resolve startup scratch space"); + let copy_directory = base.join("startup-copy"); + let home = base.join("home"); + let tmpdir = base.join("tmp"); + for directory in [&home, &tmpdir] { + fs::create_dir(directory).expect("should create private startup state"); + } + let copy = StartupCopy::create_native(&build.completed, ©_directory) + .expect("should copy the final bytes read-only"); + let build_roots: Vec<&Path> = build.host_roots.iter().map(Path::new).collect(); + let started = NativeStartupAccepted::accept_macos( + &build.completed, + copy, + &NativeStartupLayout { + copy_directory: ©_directory, + home: &home, + tmpdir: &tmpdir, + build_roots: &build_roots, + }, + ); + let started = + started.expect("should start the copy natively on the reviewed host and remove it"); + build + .completed + .rehash_for_handoff() + .expect("should leave the accepted artifact unchanged"); + started +} + +/// Encodes, prints and retains the measurement beside the Cargo fixture. +/// Returns the retained measurement so a later check can extend it. +fn retain_measurement( + fixture: &CargoFixture, + facts: &MacosCliCandidateFacts<'_>, + provenance: &serde_json::Value, +) -> serde_json::Value { + let mut measurement = macos_cli_candidate_measurement(facts); + measurement["js_inventory_sha256"] = serde_json::json!(js_inventory_sha256(facts.canonical)); + measurement["ci_build_provenance"] = provenance.clone(); + write_measurement(fixture, &measurement); + measurement +} + +/// Prints and retains `measurement` as the macOS CLI candidate measurement. +fn write_measurement(fixture: &CargoFixture, measurement: &serde_json::Value) { + let text = serde_json::to_string_pretty(measurement).expect("should encode the measurement"); + println!("{text}"); + fs::write(fixture.root.join(MEASUREMENT_FILE), text) + .expect("should retain the macOS CLI candidate measurement"); +} + +#[test] +#[ignore = "requires the explicit macOS Cargo fixture, the exported Linux JS handoff, Xcode and a native macOS 15 runner; run separately in producer CI"] +fn macos_cli_candidate_builds_inspects_and_starts_cleanly() { + // Read before measuring Xcode so a missing selection fails in seconds. + let native_reproduction = native_reproduction_from_env(); + let asset = job_host_asset(); + let target = asset.artifact().target; + let fixture = macos_cargo_fixture(); + let provenance = ci_build_provenance(&fixture); + let commit = CommitId::parse(&fixture.source_commit).expect("should parse the fixture commit"); + let source = authenticate( + &fixture.root.join("source"), + &commit, + &minimal_environment(), + ) + .expect("should authenticate the fixture's tracked source"); + let locked = LockedMaterials::from_source(&source).expect("should derive the locked materials"); + let exported = std::env::var_os("TS_RELEASE_JS_HANDOFF_ROOT") + .map(PathBuf::from) + .expect("should explicitly supply the downloaded JS handoff root"); + let exported = fs::canonicalize(exported).expect("should resolve the JS handoff root"); + let (handoff, js_measurement) = + JsBuildCompleted::read_exported_ordinary_ci_handoff_for_tests(&exported) + .expect("should read exactly the exported handoff and measurement"); + let (js, js_handoff) = + JsBuildCompleted::adopt_ordinary_ci_handoff_for_tests(&source, &handoff, &js_measurement) + .expect("should adopt the Linux job's handoff for this source"); + // One attempt covers the canonical build, the measured reproduction and + // the later Apple re-verification, SPDX and index-entry checks. On + // macos-15-intel the canonical build alone measured 24 minutes and the + // two-job reproduction is slower, so 60 minutes left no margin. + let deadline = Instant::now() + Duration::from_secs(120 * 60); + let cancellation = Cancellation::default(); + let apple = AppleDeveloperTools::for_ordinary_ci(deadline, &cancellation) + .expect("should measure the selected Xcode"); + let host = measure_host_identity().expect("should measure the macOS host"); + let (audited, removed) = environment_with_operator_values(); + let inputs = MacosInputs { + fixture: &fixture, + source: &source, + locked: &locked, + js: &js, + apple: &apple, + asset, + audited: &audited, + deadline, + cancellation: &cancellation, + }; + + let canonical = build_macos_cli(&inputs, Role::Canonical).unwrap_or_else(|error| { + panic!("should build the canonical Darwin ts through the confined recipe: {error:?}") + }); + assert_eq!( + canonical.completed.descendant_cleanup(), + DescendantCleanup::ReapedAfterRun, + "should record the post-run-reaped cleanup" + ); + let bytes = read_artifact(&canonical.completed).expect("should reread the final bytes"); + let cpu = match target { + ReleaseTarget::Aarch64AppleDarwin => MachoCpu::Arm64, + ReleaseTarget::X86_64AppleDarwin => MachoCpu::X86_64, + other => panic!("should build a Darwin asset, not {}", other.triple()), + }; + let macho = inspect_macho(&bytes, &MachoPolicy::macos(cpu)) + .expect("should accept the final bytes under the reviewed Mach-O policy"); + macho + .require_debug_map() + .expect("should keep the relative debug map"); + assert_eq!( + macho.sdk(), + apple.sdk_version(), + "should record the measured SDK" + ); + let remap_found = scan_final_bytes(&bytes, &canonical, &fixture.root); + let planted: Vec<&str> = OPERATOR_SENTINELS.iter().map(|(_, value)| *value).collect(); + check_operator_values_absent(&bytes, &planted) + .expect("should embed none of the planted operator values"); + let startup = start_natively(&canonical); + let facts = |reproduction| MacosCliCandidateFacts { + canonical: &canonical.completed, + macho: &macho, + apple: &apple, + host: &host, + js_handoff: &js_handoff, + remap_found: &remap_found, + operator_values_removed: &removed, + startup_stdout: startup.macos_stdout(), + reproduction, + }; + retain_measurement( + &fixture, + &facts(ReproductionOutcome::NotAttempted), + &provenance, + ); + + let reproduction_build = match native_reproduction { + NativeReproduction::Measure => Some(build_macos_cli(&inputs, Role::Reproduction)), + NativeReproduction::Defer => None, + }; + let paired = reproduction_build.as_ref().map(|build| { + reproduction_result( + build + .as_ref() + .map_err(|error| recipe_error(format!("{error:?}"))), + |reproduction| { + measure_native_repeatability(&canonical.completed, &reproduction.completed) + }, + ) + }); + let reproduction = match &paired { + None => ReproductionOutcome::Deferred, + Some((Ok(measured), _)) => ReproductionOutcome::Measured(measured), + Some((Err(error), Some(_))) => ReproductionOutcome::InvariantFailed(error.clone()), + Some((Err(error), None)) => ReproductionOutcome::BuildFailed(error.clone()), + }; + retain_measurement(&fixture, &facts(reproduction), &provenance); + if let Some((_, Some(error))) = &paired { + panic!("should pair builds over identical inputs: {error:?}"); + } +} diff --git a/crates/trusted-server-release/src/recipe/tests/native.rs b/crates/trusted-server-release/src/recipe/tests/native.rs new file mode 100644 index 000000000..e097c0fe3 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/native.rs @@ -0,0 +1,477 @@ +//! Native repeatability measurement, operator value scan and Linux startup +//! acceptance, over fictional builds and outputs. + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; + +use object::elf as elf_format; + +use super::super::native::{ + check_operator_values_absent, elf_section_at, measure_native_repeatability, +}; +use super::super::startup::{ + LINUX_CLI_ASSET, StartupCopy, StartupDistribution, StartupImage, check_startup_output, + expected_version_output, startup_arguments, +}; +use super::complete::{Built, built}; +use super::complete_darwin::darwin_executable; +use super::*; +use crate::inspect::tests::builders::{ElfSpec, elf}; +use crate::process::{ProcessOutcome, Stream}; + +const CLI_MANIFEST: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../trusted-server-cli/Cargo.toml" +)); +const RELEASE_MANIFEST: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")); + +/// A completed fictional build with the fixture roots its output lives in. +struct NativeBuild { + _built: Built, + completed: RecipeCompleted, +} + +/// Completes a fictional build of `asset` for `role` with exactly `bytes`. +fn native_build(asset: &'static str, role: Role, bytes: &[u8]) -> NativeBuild { + let mut built = built(asset, role, bytes); + let stdout = built.stdout(|_| {}); + let completed = built.finish(stdout).expect("should complete the build"); + NativeBuild { + _built: built, + completed, + } +} + +/// A conforming Linux executable whose `.debug_info` holds `info`. +fn linux_executable(info: &'static [u8]) -> Vec { + elf(&ElfSpec { + payload: b"/trusted-server/src/main.rs", + debug_sections: vec![ + (".debug_info", elf_format::SHT_PROGBITS, info), + ( + ".debug_line", + elf_format::SHT_PROGBITS, + b"fictional dwarf lines", + ), + ], + ..ElfSpec::conforming() + }) +} + +#[test] +fn identical_native_pair_is_measured_as_identical() { + let bytes = linux_executable(b"fictional dwarf info"); + let canonical = native_build(LINUX_CLI_ASSET, Role::Canonical, &bytes); + let reproduction = native_build(LINUX_CLI_ASSET, Role::Reproduction, &bytes); + + let measured = measure_native_repeatability(&canonical.completed, &reproduction.completed) + .expect("should measure the pair"); + + assert!(measured.identical(), "should record identical final bytes"); + assert_eq!( + measured.canonical(), + measured.reproduction(), + "should report one identity for identical bytes" + ); + assert_eq!( + measured.asset().artifact().name, + LINUX_CLI_ASSET, + "should name the measured asset" + ); +} + +#[test] +fn differing_native_pair_is_recorded_not_rejected() { + let canonical = native_build( + LINUX_CLI_ASSET, + Role::Canonical, + &linux_executable(b"fictional dwarf info"), + ); + let reproduction = native_build( + LINUX_CLI_ASSET, + Role::Reproduction, + &linux_executable(b"fictional dwarf inf0"), + ); + + let measured = measure_native_repeatability(&canonical.completed, &reproduction.completed) + .expect("should record a nonmatching native pair as a measurement"); + + assert!(!measured.identical(), "should record the difference"); + let difference = measured.difference().expect("should locate the difference"); + assert_eq!(difference.differing_bytes, 1, "should count one byte"); + assert_eq!( + difference.canonical_section.as_deref(), + Some(".debug_info"), + "should name the section holding the first difference" + ); + assert_ne!( + measured.canonical().sha256, + measured.reproduction().sha256, + "should report both digests" + ); +} + +#[test] +fn native_measurement_rejects_fastly_and_mismatched_pairs() { + let bytes = linux_executable(b"fictional dwarf info"); + let canonical = native_build(LINUX_CLI_ASSET, Role::Canonical, &bytes); + let second_canonical = native_build(LINUX_CLI_ASSET, Role::Canonical, &bytes); + assert!( + measure_native_repeatability(&canonical.completed, &second_canonical.completed).is_err(), + "should require a canonical then a reproduction build" + ); + let other = native_build( + "ts-aarch64-apple-darwin", + Role::Reproduction, + &darwin_executable(), + ); + assert!( + measure_native_repeatability(&canonical.completed, &other.completed).is_err(), + "should compare one asset only" + ); + let module = super::complete::wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let fastly = native_build("trusted-server-fastly.wasm", Role::Canonical, &module); + let fastly_reproduction = + native_build("trusted-server-fastly.wasm", Role::Reproduction, &module); + assert!( + measure_native_repeatability(&fastly.completed, &fastly_reproduction.completed).is_err(), + "should leave Fastly to the equality gate" + ); +} + +#[test] +fn elf_sections_are_named_only_inside_file_bytes() { + let bytes = linux_executable(b"fictional dwarf info"); + assert_eq!( + elf_section_at(b"fictional text, not an executable", 3), + None, + "should name nothing in non-ELF bytes" + ); + assert_eq!( + elf_section_at(&bytes, bytes.len() + 10), + None, + "should name nothing past the end" + ); +} + +#[test] +fn planted_operator_values_must_be_absent() { + let bytes = b"fictional rodata publisher.example.com"; + check_operator_values_absent(bytes, &["ts-operator-sentinel-0001.example"]) + .expect("should accept bytes without the planted value"); + for (label, values) in [ + ("present", vec!["publisher.example.com"]), + ("too short", vec!["example"]), + ("empty list", Vec::new()), + ] { + assert!( + check_operator_values_absent(bytes, &values).is_err(), + "should reject a {label} scan" + ); + } +} + +#[test] +fn startup_distributions_are_the_reviewed_tag_pinned_set() { + let labels: Vec<&str> = StartupDistribution::ALL + .iter() + .map(|distribution| distribution.label()) + .collect(); + assert_eq!(labels, ["ubi9", "ubuntu-22.04", "debian-12"]); + for distribution in StartupDistribution::ALL { + assert_eq!( + StartupDistribution::parse(distribution.label()).expect("should parse its label"), + distribution, + "should round-trip {}", + distribution.label() + ); + let tag = distribution + .reference() + .rsplit_once('/') + .and_then(|(_, name)| name.split_once(':')) + .map(|(_, tag)| tag); + assert!( + tag.is_some_and(|tag| tag != "latest" && !tag.is_empty()), + "should pin {} by an explicit tag", + distribution.label() + ); + } + assert!(StartupDistribution::parse("alpine-3").is_err()); +} + +#[test] +fn startup_images_are_selected_by_local_image_id_only() { + let id = format!("sha256:{}", "a".repeat(64)); + let image = StartupImage::new(StartupDistribution::Debian12, &id).expect("should accept"); + assert_eq!(image.id(), id, "should keep the image ID"); + for rejected in [ + "docker.io/library/debian:12".to_owned(), + format!("sha256:{}", "A".repeat(64)), + format!("sha256:{}", "a".repeat(63)), + ] { + assert!( + StartupImage::new(StartupDistribution::Debian12, &rejected).is_err(), + "should reject {rejected:?}" + ); + } +} + +#[test] +fn startup_container_is_offline_read_only_unprivileged_and_bounded() { + let config = Path::new("/example/docker-config"); + let id = format!("sha256:{}", "b".repeat(64)); + let image = StartupImage::new(StartupDistribution::Ubi9, &id).expect("should accept"); + + let arguments: Vec = startup_arguments( + config, + &image, + Path::new("/example/startup-copy"), + "ts-release-startup-example", + ) + .into_iter() + .map(|argument| argument.into_string().expect("should be UTF-8")) + .collect(); + + assert!( + arguments.iter().any(|argument| argument == "--init"), + "should reap container descendants" + ); + assert!( + arguments + .iter() + .any(|argument| argument.starts_with("--name=")), + "should own a named container for cleanup" + ); + assert!( + arguments.iter().any(|argument| argument == "create"), + "should acknowledge creation before starting guest code" + ); + assert!( + !arguments + .iter() + .any(|argument| argument == "run" || argument == "--rm"), + "should retain the created identity through explicit cleanup" + ); + let expected: Vec = [ + "--config", + "/example/docker-config", + "--host", + "unix:///var/run/docker.sock", + "create", + "--init", + "--name=ts-release-startup-example", + "--pull=never", + "--network=none", + "--read-only", + "--user=65534:65534", + "--cap-drop=ALL", + "--security-opt=no-new-privileges", + "--pids-limit=64", + "--memory=268435456", + "--cpus=1", + "--ulimit=cpu=30:30", + "--workdir=/", + "--mount=type=bind,source=/example/startup-copy,target=/opt/ts,readonly", + "--entrypoint=/opt/ts/ts", + id.as_str(), + "--version", + ] + .into_iter() + .map(str::to_owned) + .collect(); + assert_eq!( + arguments, expected, + "should run exactly the bounded container" + ); +} + +#[test] +fn startup_output_must_be_exactly_the_build_facts_with_exit_zero() { + let commit = CommitId::parse(COMMIT).expect("should parse the commit"); + let expected = expected_version_output(&commit, ReleaseTarget::X86_64UnknownLinuxGnu); + assert_eq!( + expected, + format!( + "ts package={} source={COMMIT} target=x86_64-unknown-linux-gnu variant=default\n", + env!("CARGO_PKG_VERSION") + ), + "should print the four labeled owning build facts" + ); + let distribution = StartupDistribution::Ubuntu2204; + let success = ProcessOutcome::Exited { code: 0 }; + check_startup_output(distribution, success, expected.as_bytes(), &expected) + .expect("should accept the exact version line"); + let without_newline = expected.trim_end(); + let with_fallback = format!("{expected}loaded fallback configuration\n"); + let cases: [(&str, ProcessOutcome, &[u8]); 5] = [ + ("loader failure", ProcessOutcome::Exited { code: 127 }, b""), + ("timeout", ProcessOutcome::TimedOut, expected.as_bytes()), + ( + "output cap", + ProcessOutcome::OutputLimit { + stream: Stream::Stdout, + }, + expected.as_bytes(), + ), + ("truncated line", success, without_newline.as_bytes()), + ("configuration fallback", success, with_fallback.as_bytes()), + ]; + for (label, outcome, stdout) in cases { + assert!( + check_startup_output(distribution, outcome, stdout, &expected).is_err(), + "should reject a {label}" + ); + } +} + +#[test] +fn cli_and_producer_share_the_workspace_package_version() { + assert!( + CLI_MANIFEST.contains("version = { workspace = true }"), + "should inherit the CLI version from the workspace" + ); + assert!( + RELEASE_MANIFEST.contains("version.workspace = true"), + "should inherit the producer version from the workspace" + ); +} + +#[test] +fn startup_copy_is_read_only_rehashed_and_linux_only() { + let bytes = linux_executable(b"fictional dwarf info"); + let build = native_build(LINUX_CLI_ASSET, Role::Canonical, &bytes); + let scratch = tempfile::tempdir().expect("should create scratch space"); + let base = fs::canonicalize(scratch.path()).expect("should resolve scratch space"); + let directory = base.join("startup-copy"); + + let copy = StartupCopy::create(&build.completed, &directory).expect("should copy"); + + let mode = |path: &Path| { + fs::metadata(path) + .expect("should inspect the copy") + .permissions() + .mode() + & 0o777 + }; + assert_eq!(mode(&directory), 0o555, "should seal the copy directory"); + assert_eq!(mode(&directory.join("ts")), 0o555, "should seal the copy"); + fs::set_permissions(directory.join("ts"), fs::Permissions::from_mode(0o644)) + .expect("should reopen the fictional copy for the tamper case"); + fs::write(directory.join("ts"), b"tampered").expect("should tamper with the copy"); + assert!(copy.verify().is_err(), "should detect a changed copy"); + copy.remove().expect("should remove the copy"); + assert!(!directory.exists(), "should leave nothing behind"); + + assert!( + StartupCopy::create(&build.completed, &base.join("startup,copy")).is_err(), + "should refuse a directory that would split the bind mount" + ); + let other = native_build( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + assert!( + StartupCopy::create(&other.completed, &base.join("other-copy")).is_err(), + "should start only the Linux executable" + ); +} + +#[test] +fn native_startup_copy_is_sealed_and_darwin_only() { + let darwin = native_build( + "ts-aarch64-apple-darwin", + Role::Canonical, + &darwin_executable(), + ); + let scratch = tempfile::tempdir().expect("should create scratch space"); + let base = fs::canonicalize(scratch.path()).expect("should resolve scratch space"); + let directory = base.join("native-copy"); + + let copy = StartupCopy::create_native(&darwin.completed, &directory).expect("should copy"); + + let mode = |path: &Path| { + fs::metadata(path) + .expect("should inspect the copy") + .permissions() + .mode() + & 0o777 + }; + assert_eq!(mode(&directory), 0o555, "should seal the copy directory"); + assert_eq!(mode(&directory.join("ts")), 0o555, "should seal the copy"); + copy.verify().expect("should rehash the untouched copy"); + copy.remove().expect("should remove the copy"); + + let linux = native_build( + LINUX_CLI_ASSET, + Role::Canonical, + &linux_executable(b"fictional dwarf info"), + ); + assert!( + StartupCopy::create_native(&linux.completed, &base.join("linux-copy")).is_err(), + "should start only Darwin executables natively" + ); + assert!( + StartupCopy::create_native(&darwin.completed, Path::new("relative-copy")).is_err(), + "should refuse a relative copy directory" + ); +} + +#[test] +fn startup_copy_rejects_aliases_links_and_extra_entries() { + for change in ["symlink", "hardlink", "extra", "oversize"] { + let bytes = linux_executable(b"fictional dwarf info"); + let build = native_build(LINUX_CLI_ASSET, Role::Canonical, &bytes); + let scratch = tempfile::tempdir().expect("should create scratch space"); + let base = fs::canonicalize(scratch.path()).expect("should resolve scratch space"); + let directory = base.join("startup-copy"); + let copy = StartupCopy::create(&build.completed, &directory).expect("should create copy"); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)) + .expect("should reopen directory"); + let executable = directory.join("ts"); + match change { + "symlink" => { + let outside = base.join("outside"); + fs::write(&outside, &bytes).expect("should write same bytes"); + fs::remove_file(&executable).expect("should unlink original"); + symlink(&outside, &executable).expect("should replace with alias"); + } + "hardlink" => { + fs::hard_link(&executable, base.join("second-link")).expect("should add link") + } + "extra" => { + fs::write(directory.join("extra"), b"extra").expect("should add extra entry") + } + "oversize" => { + fs::set_permissions(&executable, fs::Permissions::from_mode(0o600)) + .expect("should reopen copy"); + fs::OpenOptions::new() + .write(true) + .open(&executable) + .expect("should open copy") + .set_len(super::super::artifact::EXECUTABLE_LIMIT + 1) + .expect("should grow sparse file"); + } + _ => unreachable!(), + } + assert!(copy.verify().is_err(), "should reject {change}"); + copy.remove().expect("should remove rejected copy"); + } +} + +#[test] +fn startup_copy_drop_removes_copy_after_an_abandoned_run() { + let bytes = linux_executable(b"fictional dwarf info"); + let build = native_build(LINUX_CLI_ASSET, Role::Canonical, &bytes); + let scratch = tempfile::tempdir().expect("should create scratch space"); + let directory = fs::canonicalize(scratch.path()) + .expect("should resolve scratch space") + .join("startup-copy"); + { + let _copy = StartupCopy::create(&build.completed, &directory).expect("should copy"); + } + assert!( + !directory.exists(), + "should attempt cleanup when explicit removal is skipped" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/plan.rs b/crates/trusted-server-release/src/recipe/tests/plan.rs new file mode 100644 index 000000000..ab0cced30 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/plan.rs @@ -0,0 +1,772 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +use super::super::paths::PrefixPaths; +use super::super::plan::CargoQuery; +use super::complete::{built, wasm_with}; +use super::fixture::*; +use super::*; +use crate::policy::FIXED_ARTIFACTS; +use crate::preflight::audit_environment; + +/// The exact source-replacement pairs for the fixture's fictional Git source. +fn expected_replacement() -> Vec { + [ + "--config", + "source.crates-io.replace-with=\"ts-vendor\"", + "--config", + "source.\"git+https://example.com/org/workspace?tag=v1\".git=\"https://example.com/org/workspace\"", + "--config", + "source.\"git+https://example.com/org/workspace?tag=v1\".tag=\"v1\"", + "--config", + "source.\"git+https://example.com/org/workspace?tag=v1\".replace-with=\"ts-vendor\"", + "--config", + "source.ts-vendor.directory=\"/boundary/immutable/2/vendor\"", + ] + .into_iter() + .map(str::to_owned) + .collect() +} + +/// Execution path of the fictional Apple clang in the fixture layout. +const FIXTURE_CLANG: &str = "/boundary/system/0/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang"; + +#[test] +fn gnu_plan_pins_native_tools_and_rejects_backend_fallback() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-x86_64-unknown-linux-gnu", Role::Canonical) + .expect("should bind GNU recipe"); + let plan = prepared + .plan(&fixture.paths(Role::Canonical)) + .expect("should derive GNU plan"); + for (name, expected) in [ + ("CC_x86_64_unknown_linux_gnu", "/usr/bin/gcc"), + ("AR_x86_64_unknown_linux_gnu", "/usr/bin/ar"), + ("HOST_CC", "/usr/bin/gcc"), + ("HOST_AR", "/usr/bin/ar"), + ("AWS_LC_SYS_CMAKE_BUILDER", "0"), + ("AWS_LC_SYS_CMAKE_BUILDER_x86_64_unknown_linux_gnu", "0"), + ] { + assert_eq!( + plan.environment.get(name).map(String::as_str), + Some(expected), + "should enforce {name}" + ); + } + assert!( + plan.arguments + .iter() + .any(|argument| argument + == "target.\"x86_64-unknown-linux-gnu\".linker=\"/usr/bin/gcc\""), + "should select the reviewed linker driver" + ); + plan.confined_command(crate::process::ProcessLimits { + wall: std::time::Duration::from_secs(1), + stdout_max: 1024, + stderr_max: 1024, + }) + .expect("should accept reviewed backend controls"); +} + +#[test] +fn pure_completion_fixture_cannot_export_a_source_owned_recipe_definition() { + let bytes = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let mut build = built("trusted-server-fastly.wasm", Role::Canonical, &bytes); + let stdout = build.stdout(|_| {}); + let completed = build + .finish(stdout) + .expect("should complete the accepted pure fixture"); + assert!( + completed.recipe_definition().is_err(), + "should require the actual approved source binding to export a shared recipe definition" + ); +} + +#[test] +fn recipe_binding_keeps_checked_module_facts_and_explicit_fixture_source() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind(FIXED_ARTIFACTS[0].name, Role::Canonical) + .expect("should bind pure recipe fixture"); + assert!( + matches!(prepared.workspace, WorkspaceSelection::Fixture), + "should label the source authority bypass as test-only" + ); + assert_eq!( + prepared.js.modules.len(), + 1, + "should bind the exact prepared JS module facts" + ); + assert_eq!( + prepared.js.modules[0].id(), + "core", + "should derive module identity from the owned handoff" + ); +} + +#[test] +fn composition_queries_keep_selected_tools_environment_and_source_replacement() { + for artifact in FIXED_ARTIFACTS { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind(artifact.name, Role::Canonical) + .expect("should bind the selected recipe"); + let plan = prepared + .plan(&fixture.paths(Role::Canonical)) + .expect("should derive the selected plan"); + for kind in [CargoQuery::Metadata, CargoQuery::Tree] { + let query = plan + .observation_query(kind) + .expect("should derive the bounded query"); + assert_eq!(query.program, plan.program, "should use the selected Cargo"); + assert_eq!( + query.environment, plan.environment, + "should retain the sanitized build environment" + ); + assert_eq!( + query.working_directory, plan.working_directory, + "should retain the source root" + ); + assert_eq!(query.umask, plan.umask, "should retain the selected umask"); + let configs = |arguments: &[String]| { + arguments + .windows(2) + .filter(|pair| pair[0] == "--config") + .map(|pair| pair[1].clone()) + .collect::>() + }; + assert_eq!( + configs(&query.arguments), + configs(&plan.arguments), + "should retain every source/tool/remap config" + ); + assert!( + query.arguments.iter().any(|value| value == "--frozen"), + "should prohibit new resolution" + ); + match kind { + CargoQuery::Metadata => assert!( + !query + .arguments + .iter() + .any(|value| value == "--filter-platform" || value == "--target"), + "should retain host-only package identities in the metadata catalog" + ), + CargoQuery::Tree => assert!( + query + .arguments + .windows(2) + .any(|pair| pair == ["--target", artifact.target.triple()]), + "should select the exact target for graph observations" + ), + } + assert_ne!( + query.digest().expect("should hash the query"), + plan.digest().expect("should hash the build") + ); + } + } +} + +/// The exact target path remap for the fixture's mount layout, with the +/// Darwin debug-map prefix and target linker for a Darwin target. +fn expected_remap(target: ReleaseTarget) -> Vec { + let triple = target.triple(); + let mut flags = vec![ + "\"--remap-path-prefix=/boundary/immutable/0=/trusted-server\"", + "\"--remap-path-prefix=/boundary/immutable/2/vendor=/cargo-vendor\"", + "\"--remap-path-prefix=/boundary/writable/0/cargo-home=/cargo-home\"", + "\"--remap-path-prefix=/boundary/tool/0=/rust-tools\"", + "\"--remap-path-prefix=/boundary/writable/1=/cargo-target\"", + ]; + if target.is_darwin() { + flags.push("\"-Clink-arg=-Wl,-oso_prefix,/boundary/tool/\""); + } + let mut arguments = vec![ + "--config".to_owned(), + format!("target.\"{triple}\".rustflags=[{}]", flags.join(",")), + ]; + if target.is_darwin() { + arguments.push("--config".to_owned()); + arguments.push(format!("target.\"{triple}\".linker=\"{FIXTURE_CLANG}\"")); + } + if target == ReleaseTarget::X86_64UnknownLinuxGnu { + arguments.extend([ + "--config".to_owned(), + "target.\"x86_64-unknown-linux-gnu\".linker=\"/usr/bin/gcc\"".to_owned(), + ]); + } + if target == ReleaseTarget::Wasm32Wasip1 + && cfg!(all(target_os = "linux", target_arch = "x86_64")) + { + arguments.extend([ + "--config".to_owned(), + "target.\"x86_64-unknown-linux-gnu\".linker=\"/usr/bin/cc\"".to_owned(), + "--config".to_owned(), + "target.\"wasm32-wasip1\".linker=\"/boundary/tool/0/lib/rustlib/x86_64-unknown-linux-gnu/bin/rust-lld\"".to_owned(), + ]); + } + arguments +} + +/// The exact C path remap a native asset's plan sets for the fixture layout. +fn expected_c_prefix_map(target: ReleaseTarget) -> String { + let developer = [("/boundary/system/0", "/apple-developer")]; + [ + ("/boundary/immutable/0", "/trusted-server"), + ("/boundary/immutable/2/vendor", "/cargo-vendor"), + ("/boundary/writable/0/cargo-home", "/cargo-home"), + ("/boundary/tool/0", "/rust-tools"), + ("/boundary/writable/1", "/cargo-target"), + ] + .iter() + .chain(developer.iter().filter(|_| target.is_darwin())) + .flat_map(|(from, to)| { + [ + format!("-ffile-prefix-map={from}={to}"), + format!("-fdebug-prefix-map={from}={to}"), + ] + }) + .collect::>() + .join(" ") +} + +#[test] +fn cargo_plan_has_fixed_release_frozen_json_flags_and_exact_source_replacement() { + assert!( + !FIXED_ARTIFACTS.is_empty(), + "should select a nonzero number of assets" + ); + for (index, artifact) in FIXED_ARTIFACTS.iter().enumerate() { + let role = if index % 2 == 0 { + Role::Canonical + } else { + Role::Reproduction + }; + let jobs = if role == Role::Canonical { "4" } else { "2" }; + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind(artifact.name, role) + .expect("should bind every fixed asset"); + let plan = prepared + .plan(&fixture.paths(role)) + .expect("should derive the plan"); + let mut expected_arguments: Vec = [ + "build", + "--package", + artifact.package, + "--bin", + artifact.bin, + "--target", + artifact.target.triple(), + "--release", + "--frozen", + ] + .into_iter() + .map(str::to_owned) + .collect(); + expected_arguments.extend(expected_replacement()); + expected_arguments.extend(expected_remap(artifact.target)); + expected_arguments.extend( + ["--message-format=json-render-diagnostics", "--jobs", jobs] + .into_iter() + .map(str::to_owned), + ); + assert_eq!( + plan.arguments, expected_arguments, + "should use the exact fixed arguments for {}", + artifact.name + ); + assert_eq!( + plan.program, + fixture.base.join("tools/bin/cargo"), + "should run the selected Cargo by its host path" + ); + let checkout = if role == Role::Canonical { + "checkout-canonical" + } else { + "checkout-reproduction" + }; + assert_eq!( + plan.working_directory, + fixture.base.join(checkout), + "should run in the role checkout by its host path" + ); + assert_environment(&plan.environment, &fixture, artifact.target); + prepared + .plan(&fixture.paths(role)) + .expect("should derive the plan again"); + } +} + +fn assert_environment(actual: &BTreeMap, fixture: &Fixture, target: ReleaseTarget) { + let mut expected: BTreeMap = [ + ("AWS_LC_SYS_USE_SYSTEM", "0"), + ("CARGO_HOME", "/boundary/writable/0/cargo-home"), + ("CARGO_TARGET_DIR", "/boundary/writable/1"), + ("HOME", "/boundary/writable/0/home"), + ("LC_ALL", "C"), + ("LIBSQLITE3_SYS_USE_PKG_CONFIG", "0"), + ("PATH", "/boundary/tool/0/bin:/usr/bin:/bin"), + ("RUSTC", "/boundary/tool/0/bin/rustc"), + ("SOURCE_DATE_EPOCH", "1700000000"), + ("TMPDIR", "/boundary/writable/0/tmp"), + ("TRUSTED_SERVER__GIT_VERSION", COMMIT), + ("TRUSTED_SERVER__RELEASE_BUILD", "1"), + ( + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", + "/boundary/immutable/1/tsjs-inventory.json", + ), + ("TSJS_PREBUILT_DIR", "/boundary/immutable/1/bundles"), + ("TZ", "UTC"), + ] + .into_iter() + .map(|(name, value)| (name.to_owned(), value.to_owned())) + .collect(); + expected.insert( + format!( + "AWS_LC_SYS_USE_SYSTEM_{}", + target.triple().replace('-', "_") + ), + "0".to_owned(), + ); + let underscored = target.triple().replace('-', "_"); + if target == ReleaseTarget::X86_64UnknownLinuxGnu { + for (name, value) in [ + ("CC_x86_64_unknown_linux_gnu", "/usr/bin/gcc"), + ("AR_x86_64_unknown_linux_gnu", "/usr/bin/ar"), + ("HOST_CC", "/usr/bin/gcc"), + ("HOST_AR", "/usr/bin/ar"), + ("AWS_LC_SYS_CMAKE_BUILDER", "0"), + ("AWS_LC_SYS_CMAKE_BUILDER_x86_64_unknown_linux_gnu", "0"), + ] { + expected.insert(name.to_owned(), value.to_owned()); + } + } + if target == ReleaseTarget::Wasm32Wasip1 + && cfg!(all(target_os = "linux", target_arch = "x86_64")) + { + expected.insert("HOST_CC".to_owned(), "/usr/bin/cc".to_owned()); + expected.insert("HOST_AR".to_owned(), "/usr/bin/ar".to_owned()); + } + if target.is_darwin() { + let bin = "/boundary/system/0/Toolchains/XcodeDefault.xctoolchain/usr/bin"; + for (name, value) in [ + ("MACOSX_DEPLOYMENT_TARGET".to_owned(), "13.0".to_owned()), + ("DEVELOPER_DIR".to_owned(), "/boundary/system/0".to_owned()), + ( + "SDKROOT".to_owned(), + format!("/boundary/system/0/{SDK_ROOT}"), + ), + (format!("CC_{underscored}"), FIXTURE_CLANG.to_owned()), + (format!("AR_{underscored}"), format!("{bin}/ar")), + ("ZERO_AR_DATE".to_owned(), "1".to_owned()), + ("OPENSSL_CONF".to_owned(), "/dev/null".to_owned()), + ] { + expected.insert(name, value); + } + } + if target != ReleaseTarget::Wasm32Wasip1 { + expected.insert( + format!("CFLAGS_{underscored}"), + expected_c_prefix_map(target), + ); + } + assert_eq!( + actual, &expected, + "should hold exactly the child environment with every path in the execution namespace" + ); + for (name, value) in actual { + assert!( + !value.contains(fixture.base.to_str().expect("should be UTF-8")), + "should not leak a host path into {name}" + ); + } +} + +/// One mapped location per path-carrying variable, so each can be omitted alone. +fn granular(fixture: &Fixture, omit: &[&str]) -> PrefixPaths { + let retained = fixture.retained(); + let all: [(&str, PathBuf, &str, bool); 12] = [ + ("developer", fixture.developer_dir(), "/c/developer", false), + ("target", fixture.base.join("target"), "/c/target", true), + ( + "cargo-home", + fixture.base.join("cargo-state/cargo-home"), + "/c/cargo-home", + true, + ), + ( + "home", + fixture.base.join("cargo-state/home"), + "/c/home", + true, + ), + ("tmp", fixture.base.join("cargo-state/tmp"), "/c/tmp", true), + ("bundles", retained.join("bundles"), "/c/bundles", false), + ( + "inventory", + retained.join("tsjs-inventory.json"), + "/c/inventory.json", + false, + ), + ( + "rustc", + fixture.base.join("tools/bin/rustc"), + "/c/rustc", + false, + ), + ("bin", fixture.base.join("tools/bin"), "/c/bin", false), + ( + "vendor", + fixture.base.join("materials/vendor"), + "/c/vendor", + false, + ), + ( + "checkout", + fixture.base.join("checkout-canonical"), + "/c/checkout", + false, + ), + ("tools", fixture.base.join("tools"), "/c/tools", false), + ]; + all.iter().filter(|(name, ..)| !omit.contains(name)).fold( + PrefixPaths::new(), + |paths, (_, host, execution, writable)| paths.with(host, execution, *writable), + ) +} + +#[test] +fn path_variables_outside_every_root_are_rejected() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the recipe"); + prepared + .plan(&granular(&fixture, &[])) + .expect("should derive the plan when every location is mapped"); + let cases: [(&str, &[&str]); 11] = [ + ("Apple developer directory", &["developer"]), + ("CARGO_TARGET_DIR", &["target"]), + ("CARGO_HOME", &["cargo-home"]), + ("HOME", &["home"]), + ("TMPDIR", &["tmp"]), + ("TSJS_PREBUILT_DIR", &["bundles"]), + ("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", &["inventory"]), + ("RUSTC", &["rustc", "bin", "tools"]), + ("vendor directory", &["vendor"]), + ("checkout root", &["checkout"]), + ("Rust tools root", &["tools"]), + ]; + for (label, omit) in cases { + let message = plan_error(&prepared, &granular(&fixture, omit)); + assert!( + message.contains(label) && message.contains("outside the boundary roots"), + "should reject {label} outside the roots: {message}" + ); + } + let message = plan_error(&prepared, &granular(&fixture, &["bin", "tools"])); + assert!( + message.contains("PATH") && message.contains("outside the boundary roots"), + "should reject the tool directory in PATH outside the roots: {message}" + ); +} + +#[test] +fn child_home_and_cargo_home_must_be_the_request_state() { + let fixture = Fixture::new(COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); + let commit = CommitId::parse(COMMIT).expect("should parse commit"); + let mut forged = child_request(&request); + forged.home = fixture.base.join("other-work/home"); + let child = + crate::preflight::child::build_child_for_tests(&audited(), &commit, 1_700_000_000, &forged) + .expect("should build a child with another HOME"); + let error = PreparedRecipe::bind(BindInputs { + workspace: WorkspaceSelection::Fixture, + commit: &commit, + child, + completed: &fixture.completed, + tools: fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin), + materials: fixture.materials(COMMIT), + ambient: fixture.ambient(Role::Canonical), + request, + }); + assert!( + bind_error(error).contains("HOME is not the request's private cargo state"), + "should reject a child HOME outside the request state" + ); +} + +#[test] +fn cargo_plan_rejects_inherited_rustflags_and_selectors() { + for name in [ + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_BUILD_TARGET", + "RUSTUP_TOOLCHAIN", + "CC", + "TSJS_SKIP_BUILD", + ] { + let snapshot = [(OsString::from(name), OsString::from("fictional"))]; + let rejection = match audit_environment(&snapshot) { + Ok(_) => panic!("should reject inherited {name}"), + Err(error) => format!("{error:?}"), + }; + assert!( + rejection.contains(name), + "should reject inherited {name} before any plan exists" + ); + } + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-x86_64-unknown-linux-gnu", Role::Canonical) + .expect("should bind from a benign environment"); + let plan = prepared + .plan(&fixture.paths(Role::Canonical)) + .expect("should derive the plan"); + for name in plan.environment.keys() { + assert!( + name == "RUSTC" || (!name.starts_with("RUST") && !name.starts_with("CARGO_BUILD")), + "should carry no selector besides the explicit RUSTC: {name}" + ); + } + let configs: Vec<&String> = plan + .arguments + .iter() + .enumerate() + .filter(|(_, argument)| *argument == "--config") + .filter_map(|(index, _)| plan.arguments.get(index + 1)) + .collect(); + assert_eq!( + configs.len(), + expected_replacement().len() / 2 + 2, + "should carry the exact source replacements, path remap and reviewed GNU linker" + ); + assert_eq!( + configs.last().map(|config| config.as_str()), + expected_remap(ReleaseTarget::X86_64UnknownLinuxGnu) + .last() + .map(String::as_str), + "should select the reviewed GNU linker after target path remapping" + ); + for argument in &plan.arguments { + assert!( + ![ + "--features", + "--all-features", + "--no-default-features", + "--manifest-path", + "--config=", + ] + .iter() + .any(|selector| argument.starts_with(selector)) + && !argument.starts_with('+'), + "should not carry selector argument {argument}" + ); + } + assert!( + plan.arguments + .iter() + .any(|argument| argument == "--release") + && plan.arguments.iter().any(|argument| argument == "--frozen"), + "should keep the release and frozen flags" + ); +} + +#[test] +fn plan_digest_binds_every_part_of_the_plan() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the recipe"); + let paths = fixture.paths(Role::Canonical); + let plan = prepared.plan(&paths).expect("should derive the plan"); + let digest = plan.digest().expect("should digest the plan"); + assert_eq!(digest.len(), 64, "should be a SHA-256 hex digest"); + assert_eq!( + digest, + prepared + .plan(&paths) + .expect("should derive the plan again") + .digest() + .expect("should digest again"), + "should be a pure function of the bound inputs" + ); + type Edit = fn(&mut CargoPlan); + let edits: [(&str, Edit); 6] = [ + ("argument", |p| p.arguments.push("--offline".to_owned())), + ("dropped argument", |p| { + p.arguments.retain(|argument| argument != "--release"); + }), + ("environment value", |p| { + p.environment + .insert("RUSTC".to_owned(), "/other/rustc".to_owned()); + }), + ("injected variable", |p| { + p.environment + .insert("RUSTFLAGS".to_owned(), "--cfg x".to_owned()); + }), + ("program", |p| p.program = PathBuf::from("/other/cargo")), + ("umask", |p| p.umask = umask(0o000)), + ]; + for (label, edit) in edits { + let mut changed = prepared.plan(&paths).expect("should derive the plan"); + edit(&mut changed); + assert_ne!( + changed.digest().expect("should digest the edited plan"), + digest, + "should change the digest for an edited {label}" + ); + } + let mut moved = prepared.plan(&paths).expect("should derive the plan"); + moved.working_directory = fixture.base.join("checkout-reproduction"); + assert_ne!( + moved.digest().expect("should digest the moved plan"), + digest, + "should change the digest for another working directory" + ); +} + +#[test] +fn plan_carries_the_role_umask() { + for (role, expected) in [(Role::Canonical, 0o022), (Role::Reproduction, 0o027)] { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", role) + .expect("should bind the recipe"); + assert_eq!( + prepared + .plan(&fixture.paths(role)) + .expect("should derive the plan") + .umask, + umask(expected), + "should derive the umask of role {role:?} from its ambient profile" + ); + } +} + +#[test] +fn confined_command_applies_the_role_umask_and_keeps_host_program_paths() { + for (role, expected) in [(Role::Canonical, "022"), (Role::Reproduction, "027")] { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", role) + .expect("should bind the recipe"); + let plan = prepared + .plan(&fixture.paths(role)) + .expect("should derive the plan"); + let command = plan + .confined_command(confinement_limits()) + .expect("should describe the confined command"); + assert_eq!( + command + .requested_umask() + .map(|umask| umask.to_string()) + .as_deref(), + Some(expected), + "should set the umask of role {role:?} on the confined command" + ); + let described = format!("{command:?}"); + for part in [ + format!("{:?}", plan.program), + format!("{:?}", plan.working_directory), + format!( + "{:?}", + plan.arguments + .iter() + .map(OsString::from) + .collect::>() + ), + ] { + assert!( + described.contains(&part), + "should describe {part} in {described}" + ); + } + } +} + +#[cfg(target_os = "macos")] +#[test] +fn real_boundary_translates_every_plan_path() { + use crate::boundary::Boundary; + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the recipe"); + let roots = prepared.boundary_roots().expect("should derive roots"); + let boundary = Boundary::mac_sandbox_for_tests(roots).expect("should configure, not run"); + let plan = prepared + .plan(&boundary) + .expect("should derive the plan through the boundary"); + assert_eq!( + plan.environment.get("CARGO_TARGET_DIR").map(String::as_str), + fixture.base.join("target").to_str(), + "should map writable roots to themselves on macOS" + ); + assert!( + plan.environment["PATH"].starts_with( + fixture + .base + .join("tools/bin") + .to_str() + .expect("should be UTF-8") + ), + "should put the selected tool directory first in PATH" + ); +} + +#[test] +fn execution_paths_with_control_characters_or_separators_are_rejected() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::Canonical) + .expect("should bind the recipe"); + for bad in [ + "/c/bin\nx", + "/c/bin\tx", + "/c/bin\u{7f}x", + "/c/bi:n", + "/c/bi\"n", + "/c/bi\\n", + ] { + let paths = PrefixPaths::new() + .with(&fixture.base.join("tools"), bad, false) + .with(&fixture.retained(), "/c/retained", false) + .with(&fixture.base, "/c/base", true); + let message = plan_error(&prepared, &paths); + assert!( + message.contains("not plain absolute UTF-8"), + "should reject execution path {bad:?}: {message}" + ); + } +} + +#[test] +fn fake_host_path_rejects_non_normal_components_like_the_real_inverse() { + let paths = PrefixPaths::new().with(std::path::Path::new("/host/target"), "/c/target", true); + assert!( + paths + .host_path(std::path::Path::new("/c/target/release/ts")) + .is_ok() + ); + for bad in [ + "/c/target/../target/ts", + "/c/target/./ts", + "/c/target//ts", + "/c/target/ts/", + ] { + assert!( + paths.host_path(std::path::Path::new(bad)).is_err(), + "should reject {bad}" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe/tests/real_fixture.rs b/crates/trusted-server-release/src/recipe/tests/real_fixture.rs new file mode 100644 index 000000000..5926e0a6f --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/real_fixture.rs @@ -0,0 +1,253 @@ +//! Helpers shared by the explicit real ordinary CI fixtures on both the +//! Linux container and the macOS sandbox hosts; never selected by a normal +//! focused unit run. +//! +//! Every run that uses them is ordinary CI candidate evidence +//! (`release_qualification: false`): the toolchain and fixture acquisition +//! are unqualified and unauthenticated, so nothing here qualifies a release. + +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use super::candidate::CargoFixture; +use super::cli_candidate::OPERATOR_SENTINELS; +use super::*; +use crate::documents::{parse_json, read_bounded}; +use crate::materials::{GitVendorInput, LockedMaterials, VendorInputs}; +use crate::preflight::child::build_child_for_tests; +use crate::preflight::{AuditedEnvironment, audit_environment}; +use crate::process::Cancellation; + +/// The committed denial fixture workspace. +const DENIAL_FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/cargo-denial"); +/// Fictional committer epoch of the denial fixture child environment. +const DENIAL_EPOCH: u64 = 1_700_000_000; +/// Members that attempt a connection while Cargo builds them. +pub(super) const ATTEMPTS: [&str; 3] = ["build-direct", "build-grandchild", "macro-attempt"]; + +/// Reads the ordinary CI Cargo fixture named by `TS_RELEASE_CARGO_FIXTURE_ROOT`. +pub(super) fn cargo_fixture() -> CargoFixture { + let root = std::env::var_os("TS_RELEASE_CARGO_FIXTURE_ROOT") + .map(PathBuf::from) + .expect("should explicitly supply the ordinary CI Cargo fixture root"); + let root = fs::canonicalize(root).expect("should resolve the Cargo fixture root"); + let raw = read_bounded(&root.join("fixture.json"), DocumentRole::Metadata) + .expect("should read the Cargo fixture description"); + let document = + parse_json(&raw.bytes, DocumentRole::Metadata).expect("should parse the description"); + CargoFixture::parse(&root, &document).expect("should accept the Cargo fixture description") +} + +/// Audits the minimal source environment. +pub(super) fn minimal_environment() -> AuditedEnvironment { + audit_environment(&[(OsString::from("PATH"), OsString::from("/usr/bin:/bin"))]) + .expect("should audit the minimal environment") +} + +/// Audits an inherited environment carrying the planted operator values and +/// requires preflight to remove every one of them. +pub(super) fn environment_with_operator_values() -> (AuditedEnvironment, Vec) { + let mut snapshot = vec![(OsString::from("PATH"), OsString::from("/usr/bin:/bin"))]; + snapshot.extend( + OPERATOR_SENTINELS + .iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))), + ); + let audited = audit_environment(&snapshot).expect("should remove, not reject, operator values"); + let removed = audited.removed_names().to_vec(); + assert_eq!( + removed.len(), + OPERATOR_SENTINELS.len(), + "should remove every planted operator variable" + ); + (audited, removed) +} + +/// Conditions of an ordinary CI build of `role`. +/// +/// The umask and jobs are the profile values the plan enforces on the child. +/// The reproduction build uses the test-only alternate profile: its host locale and timezone +/// are profile labels, while the child runs with the normalized `C` locale +/// and `UTC` timezone either way. This is not an independent qualified +/// observation, and the production reproduction build stays unqualified. +pub(super) fn ordinary_ci_ambient(role: Role, checkout: &Path) -> CheckedAmbient { + try_ordinary_ci_ambient(role, checkout).expect("should match the role profile") +} + +pub(super) fn try_ordinary_ci_ambient( + role: Role, + checkout: &Path, +) -> Result> { + let (profile, base) = match role { + Role::Canonical => (AmbientProfile::CANONICAL, observed_canonical()), + Role::Reproduction => ( + AmbientProfile::alternate_for_tests(), + observed_reproduction(), + ), + }; + // Reading the real user ID has no preconditions. + let execution_uid = unsafe { libc::getuid() }; + let started_at_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| recipe_error(format!("cannot read ambient clock: {error}")))? + .as_secs(); + CheckedAmbient::check( + role, + &profile, + ObservedAmbient { + execution_uid, + checkout_root: checkout.to_owned(), + started_at_seconds, + ..base + }, + ) +} + +/// Copies a tree of directories and regular files into a new directory. +fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir(destination).expect("should create the copied directory"); + for entry in fs::read_dir(source).expect("should list the fixture directory") { + let entry = entry.expect("should read the fixture entry"); + let kind = entry.file_type().expect("should inspect the fixture entry"); + let target = destination.join(entry.file_name()); + if kind.is_dir() { + copy_tree(&entry.path(), &target); + } else if kind.is_file() { + fs::copy(entry.path(), &target).expect("should copy the fixture file"); + } else { + panic!("should hold only directories and regular files"); + } + } +} + +/// One bound denial fixture member with the roots it owns. +pub(super) struct DenialCase { + _work: tempfile::TempDir, + _completed: JsBuildCompleted, + pub(super) prepared: PreparedRecipe, +} + +/// What one host builds a denial fixture member as. +pub(super) struct DenialSelection { + /// The test-only fixture row of the member for the host's target. + pub(super) asset: AssetId, + /// The ordinary CI Rust tools, with Apple developer tools on macOS. + pub(super) tools: SelectedRustTools, + /// A fixed target directory, or `None` for one inside the case roots. + pub(super) target_dir: Option, +} + +/// Binds one denial fixture member over fresh private roots. +pub(super) fn bind_denial_case( + fixture: &CargoFixture, + member: &str, + selection: DenialSelection, +) -> DenialCase { + let work = tempfile::tempdir().expect("should create the case roots"); + let base = fs::canonicalize(work.path()).expect("should resolve the case roots"); + let checkout = base.join("checkout"); + copy_tree(Path::new(DENIAL_FIXTURE), &checkout); + for directory in ["materials/vendor", "cargo-state", "output"] { + fs::create_dir_all(base.join(directory)).expect("should create a case root"); + } + let completed = JsBuildCompleted::fixture_for_tests(&fixture.source_commit); + let retained = fs::canonicalize( + completed + .rehash_for_handoff() + .expect("should rehash the fixture handoff"), + ) + .expect("should resolve the retained root"); + let target_dir = selection.target_dir.unwrap_or_else(|| base.join("target")); + let request = RecipeRequest::parse_for_asset( + selection.asset, + &RecipeInput { + asset: member, + variant: "default", + role: Role::Canonical.spec_label(), + commit: &fixture.source_commit, + js_dir: &retained.join("bundles"), + js_inventory: &retained.join("tsjs-inventory.json"), + materials: &base.join("materials"), + cargo_state: &base.join("cargo-state"), + output: &base.join("output"), + target_dir: &target_dir, + }, + ) + .expect("should accept the case request"); + let commit = CommitId::parse(&fixture.source_commit).expect("should parse the commit"); + let child = build_child_for_tests( + &minimal_environment(), + &commit, + DENIAL_EPOCH, + &child_request(&request), + ) + .expect("should build the child environment"); + let prepared = PreparedRecipe::bind(BindInputs { + workspace: WorkspaceSelection::Fixture, + commit: &commit, + child, + completed: &completed, + tools: selection.tools, + materials: PreparedCargoMaterials::for_tests(&base.join("materials"), commit.as_str(), &[]) + .expect("should describe the empty vendor directory"), + ambient: ordinary_ci_ambient(Role::Canonical, &checkout), + request, + }) + .expect("should bind the denial case"); + DenialCase { + _work: work, + _completed: completed, + prepared, + } +} + +/// Prepares the vendor tree from the fixture's retained originals. +/// +/// The fixture's `cargo vendor` manifests only name which locked Git packages +/// to supply closures for; the producer normalizes every inheriting manifest +/// itself from the verified tree. +pub(super) fn try_prepare_vendor( + fixture: &CargoFixture, + locked: &LockedMaterials, + root: &Path, + deadline: Instant, + cancellation: &Cancellation, +) -> Result> { + let collection = locked.git_collection(Path::new("/usr/bin/git"), deadline, cancellation)?; + let verified: Vec<_> = fixture + .git + .iter() + .map(|source| collection.verify(&source.selector, &source.revision, &source.objects)) + .collect::>()?; + let inputs: Vec> = fixture + .comparison_manifests + .iter() + .map(|package| { + let index = fixture + .git + .iter() + .position(|source| source.selector == package.selector) + .ok_or_else(|| recipe_error("comparison manifest names no retained Git source"))?; + Ok(GitVendorInput { + material: &verified[index], + name: &package.name, + version: &package.version, + }) + }) + .collect::>>()?; + locked.prepare_cargo_vendor( + &VendorInputs { + registry_archives: &fixture.registry, + git: &inputs, + }, + root, + deadline, + cancellation, + ) +} + +pub(super) fn path_text(path: &Path) -> String { + path.to_str().expect("should be a UTF-8 path").to_owned() +} diff --git a/crates/trusted-server-release/src/recipe/tests/remap.rs b/crates/trusted-server-release/src/recipe/tests/remap.rs new file mode 100644 index 000000000..f5bba8c31 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/remap.rs @@ -0,0 +1,321 @@ +use super::super::paths::PrefixPaths; +use super::super::remap::{ + REMAPPED_PREFIXES, RemapSources, c_prefix_map_environment, check_cargo_config, + check_no_extensionless_config, remap_arguments, scan_remapped_bytes, +}; +use super::fixture::*; +use super::*; + +const TRACKED_CARGO_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../.cargo/config.toml" +)); + +/// The `--config` value of `plan`'s remap entry. +fn remap_config(plan: &CargoPlan) -> &str { + plan.arguments + .iter() + .rfind(|argument| argument.contains(".rustflags=")) + .expect("should carry the remap config") +} + +fn sources<'a>(checkout: &'a str, vendor: &'a str) -> RemapSources<'a> { + RemapSources { + checkout, + vendor, + cargo_home: "/x/state/cargo-home", + tools: "/x/tools", + target: "/x/target", + } +} + +#[test] +fn remap_maps_every_source_root_to_a_fixed_prefix_in_a_fixed_order() { + let arguments = remap_arguments("wasm32-wasip1", &sources("/x/checkout", "/x/vendor")) + .expect("should remap disjoint roots"); + assert_eq!( + arguments, + [ + "--config".to_owned(), + [ + "target.\"wasm32-wasip1\".rustflags=[", + "\"--remap-path-prefix=/x/checkout=/trusted-server\",", + "\"--remap-path-prefix=/x/vendor=/cargo-vendor\",", + "\"--remap-path-prefix=/x/state/cargo-home=/cargo-home\",", + "\"--remap-path-prefix=/x/tools=/rust-tools\",", + "\"--remap-path-prefix=/x/target=/cargo-target\"]", + ] + .concat(), + ], + "should emit checkout, vendor, Cargo home, tools, target in that order \ + through one quoted target rustflags entry" + ); + assert_eq!( + REMAPPED_PREFIXES, + [ + "/trusted-server", + "/cargo-vendor", + "/cargo-home", + "/rust-tools", + "/cargo-target" + ], + "should keep the fixed prefixes in emitted order" + ); +} + +#[test] +fn nested_or_equal_remapped_roots_are_rejected() { + for (checkout, vendor) in [ + ("/x/checkout", "/x/checkout/vendor"), + ("/x/vendor/inner", "/x/vendor"), + ("/x/same", "/x/same"), + ("/x/checkout", "/x/tools"), + ] { + let error = remap_arguments("wasm32-wasip1", &sources(checkout, vendor)) + .expect_err("should reject overlapping roots whose order would decide the path"); + assert!( + format!("{error:?}").contains("overlap"), + "should name the overlap for {checkout} and {vendor}" + ); + } +} + +#[test] +fn plan_rejects_remapped_roots_that_overlap_in_the_execution_namespace() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("trusted-server-fastly.wasm", Role::Canonical) + .expect("should bind the recipe"); + let nested = PrefixPaths::new() + .with( + &fixture.base.join("materials"), + "/boundary/immutable/0/materials", + false, + ) + .with( + &fixture.base.join("checkout-canonical"), + "/boundary/immutable/0", + false, + ) + .with(&fixture.retained(), "/boundary/immutable/1", false) + .with(&fixture.base.join("tools"), "/boundary/tool/0", false) + .with( + &fixture.base.join("cargo-state"), + "/boundary/writable/0", + true, + ) + .with(&fixture.base.join("target"), "/boundary/writable/1", true); + assert!( + plan_error(&prepared, &nested).contains("overlap"), + "should reject a vendor tree mounted inside the checkout" + ); +} + +#[test] +fn remap_is_identical_for_both_roles_in_the_same_mount_layout() { + let fixture = Fixture::new(COMMIT); + let canonical = fixture + .bind("trusted-server-fastly.wasm", Role::Canonical) + .expect("should bind the canonical build") + .plan(&fixture.paths(Role::Canonical)) + .expect("should plan the canonical build"); + let other = Fixture::new(COMMIT); + let reproduction = other + .bind("trusted-server-fastly.wasm", Role::Reproduction) + .expect("should bind the reproduction build") + .plan(&other.paths(Role::Reproduction)) + .expect("should plan the reproduction build"); + assert_ne!( + canonical.working_directory, reproduction.working_directory, + "should run from distinct host checkouts" + ); + assert_eq!( + remap_config(&canonical), + remap_config(&reproduction), + "should hand both roles the same remap in the same execution layout" + ); + for plan in [&canonical, &reproduction] { + assert_eq!( + ( + plan.environment.get("LC_ALL").map(String::as_str), + plan.environment.get("TZ").map(String::as_str), + ), + (Some("C"), Some("UTC")), + "should keep the compiler locale and timezone normalized for each role" + ); + } +} + +#[test] +fn remap_follows_identity_paths_to_the_same_fixed_prefixes() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("trusted-server-fastly.wasm", Role::Canonical) + .expect("should bind the recipe"); + let base = fixture.base.to_str().expect("should be UTF-8"); + let retained = fixture.retained(); + let identity = PrefixPaths::new().with(&fixture.base, base, true).with( + &retained, + retained.to_str().expect("should be UTF-8"), + false, + ); + let plan = prepared + .plan(&identity) + .expect("should plan with host paths"); + assert_eq!( + remap_config(&plan), + format!( + "target.\"wasm32-wasip1\".rustflags=[\ + \"--remap-path-prefix={base}/checkout-canonical=/trusted-server\",\ + \"--remap-path-prefix={base}/materials/vendor=/cargo-vendor\",\ + \"--remap-path-prefix={base}/cargo-state/cargo-home=/cargo-home\",\ + \"--remap-path-prefix={base}/tools=/rust-tools\",\ + \"--remap-path-prefix={base}/target=/cargo-target\"]" + ), + "should remap host-identical execution paths, as on macOS, to the fixed prefixes" + ); +} + +#[test] +fn tracked_cargo_config_keeps_target_rustflags_reviewable() { + check_cargo_config(TRACKED_CARGO_CONFIG.as_bytes()) + .expect("should accept the tracked Cargo configuration"); + check_cargo_config(b"[target.wasm32-wasip1]\nrustflags = [\"--cfg\", \"example\"]\n") + .expect("should accept target rustflags, which the remap extends"); + check_cargo_config(b"[build]\njobs = 2\n").expect("should accept other build keys"); +} + +#[test] +fn cargo_config_that_the_remap_would_supersede_is_rejected() { + for (label, bytes) in [ + ( + "build rustflags", + &b"[build]\nrustflags = [\"--cfg\", \"example\"]\n"[..], + ), + ( + "dotted build rustflags", + b"build.rustflags = \"--cfg example\"\n", + ), + ("non-UTF-8", b"\xff\xfe"), + ("non-TOML", b"[build\n"), + ] { + assert!( + check_cargo_config(bytes).is_err(), + "should reject a Cargo configuration with {label}" + ); + } +} + +#[test] +fn extensionless_cargo_config_is_rejected_tracked_or_on_disk() { + let directory = tempfile::tempdir().expect("should create a checkout"); + let checkout = directory.path(); + std::fs::create_dir(checkout.join(".cargo")).expect("should create .cargo"); + std::fs::write(checkout.join(".cargo/config.toml"), b"[alias]\n") + .expect("should write the reviewed configuration"); + check_no_extensionless_config(checkout, false) + .expect("should accept a checkout with only config.toml"); + assert!( + format!( + "{:?}", + check_no_extensionless_config(checkout, true).expect_err("should reject tracked") + ) + .contains("tracks .cargo/config"), + "should reject a tracked extensionless configuration" + ); + std::os::unix::fs::symlink("config.toml", checkout.join(".cargo/config")) + .expect("should alias the legacy name"); + assert!( + format!( + "{:?}", + check_no_extensionless_config(checkout, false).expect_err("should reject on disk") + ) + .contains("holds .cargo/config"), + "should reject an extensionless configuration present on disk, even as a link" + ); +} + +#[test] +fn remap_scan_requires_a_fixed_prefix_and_no_unremapped_path() { + let clean = b"\0asm../trusted-server/crates/core/src/lib.rs\0/rustc/abc/library"; + let scan = scan_remapped_bytes(clean, &["/tmp/fixture-root", "/home/runner/work/_temp"]) + .expect("should accept remapped bytes"); + assert_eq!( + scan.found(), + ["/trusted-server"], + "should list found prefixes" + ); + for (label, bytes) in [ + ("container mount", &b"x/boundary/immutable/0/src/lib.rs"[..]), + ( + "host fixture root", + b"/trusted-server/a /tmp/fixture-root/x", + ), + ( + "host runner temp", + b"/cargo-vendor/a /home/runner/work/_temp/y", + ), + ("no fixed prefix", b"/rustc/abc/library/core/src/lib.rs"), + ("prefix without separator", b"/trusted-server-core"), + ] { + assert!( + scan_remapped_bytes(bytes, &["/tmp/fixture-root", "/home/runner/work/_temp"]).is_err(), + "should reject bytes with {label}" + ); + } + let too_many = vec!["/x"; 65]; + for forbidden in [&too_many[..], &[""][..]] { + assert!( + scan_remapped_bytes(clean, forbidden).is_err(), + "should bound the forbidden prefixes" + ); + } +} + +#[test] +fn native_c_code_gets_the_same_remap_and_wasm_gets_none() { + let roots = sources("/x/checkout", "/x/vendor"); + assert_eq!( + c_prefix_map_environment(ReleaseTarget::Wasm32Wasip1, &roots) + .expect("should accept disjoint roots"), + None, + "should leave the Fastly plan without C flags" + ); + let (name, value) = c_prefix_map_environment(ReleaseTarget::X86_64UnknownLinuxGnu, &roots) + .expect("should accept disjoint roots") + .expect("should remap C code of a native asset"); + assert_eq!( + name, "CFLAGS_x86_64_unknown_linux_gnu", + "should use the target-scoped name cc reads first" + ); + assert_eq!( + value, + [ + "-ffile-prefix-map=/x/checkout=/trusted-server", + "-fdebug-prefix-map=/x/checkout=/trusted-server", + "-ffile-prefix-map=/x/vendor=/cargo-vendor", + "-fdebug-prefix-map=/x/vendor=/cargo-vendor", + "-ffile-prefix-map=/x/state/cargo-home=/cargo-home", + "-fdebug-prefix-map=/x/state/cargo-home=/cargo-home", + "-ffile-prefix-map=/x/tools=/rust-tools", + "-fdebug-prefix-map=/x/tools=/rust-tools", + "-ffile-prefix-map=/x/target=/cargo-target", + "-fdebug-prefix-map=/x/target=/cargo-target", + ] + .join(" "), + "should map every root to the same fixed prefix rustc uses" + ); +} + +#[test] +fn native_c_remap_rejects_split_or_overlapping_roots() { + for (label, roots) in [ + ("whitespace", sources("/x/check out", "/x/vendor")), + ("nested", sources("/x/checkout", "/x/checkout/vendor")), + ] { + assert!( + c_prefix_map_environment(ReleaseTarget::X86_64UnknownLinuxGnu, &roots).is_err(), + "should reject a {label} root" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe/tests/runtime.rs b/crates/trusted-server-release/src/recipe/tests/runtime.rs new file mode 100644 index 000000000..28582798e --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/runtime.rs @@ -0,0 +1,620 @@ +//! Pure tests of the final-byte Fastly runtime gate. +//! +//! None of these start Viceroy. The real gate runs only in the ordinary CI +//! canonical and reproduction job (see `full.rs`). + +use std::net::SocketAddr; +use std::time::Duration; + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::super::runtime::tests_support::{ + HttpResponse, OriginFixture, app_config_data, canonical, check_publisher_response, + config_store_id, envelope_json, get, is_health_ok, origin_page, parse_response, + parse_version_output, require_built_source_fixtures, sanitized_tail, scan_viceroy_output, + viceroy_config, +}; +use super::super::runtime::{ + OriginPresence, PINNED_VICEROY_VERSION, RuntimeRejection, SelectedViceroy, run_gate, +}; +use crate::error::ReleaseError; +use crate::inspect::Violation; +use crate::inspect::tests::builders::{Entry, append_customs, command_module}; + +const TOOL_VERSIONS: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../.tool-versions")); +const FASTLY_TOML: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../fastly.toml")); +const ORIGIN: &str = "http://127.0.0.1:9999"; +const MARKER: &str = "trusted-server-release-origin-0123456789abcdef01234567"; +const SCRIPT: &str = + ""; + +/// Returns the attached [`RuntimeRejection`] of a runtime rejection. +fn rejection(result: Result>) -> RuntimeRejection { + let report = result.expect_err("should reject"); + assert!( + matches!(report.current_context(), ReleaseError::Runtime), + "should report a runtime rejection: {report:?}" + ); + *report + .downcast_ref::() + .expect("should attach the runtime rejection") +} + +/// Returns the attached inspection [`Violation`]. +fn violation(result: Result>) -> Violation { + let report = result.expect_err("should reject"); + assert!( + matches!(report.current_context(), ReleaseError::Inspect), + "should report an inspection rejection: {report:?}" + ); + *report + .downcast_ref::() + .expect("should attach the violated rule") +} + +#[test] +fn pinned_viceroy_version_matches_tool_versions() { + let pinned = TOOL_VERSIONS + .lines() + .find_map(|line| line.strip_prefix("viceroy")) + .map(str::trim) + .expect("should pin Viceroy in .tool-versions"); + assert_eq!( + pinned, PINNED_VICEROY_VERSION, + "should gate on the tracked pin" + ); +} + +#[test] +fn viceroy_version_output_must_be_exactly_the_pin() { + assert_eq!( + parse_version_output(b"viceroy 0.21.0\n").expect("should accept the pinned output"), + "0.21.0" + ); + for output in [ + &b""[..], + b"viceroy 0.21.0", + b"viceroy 0.21.1\n", + b"viceroy 0.17.0\n", + b"viceroy 0.16.0\n", + b"viceroy 0.21.0-rc.1\n", + b"Viceroy 0.21.0\n", + b"viceroy 0.21.0\n", + b"viceroy 0.21.0\nextra\n", + b"fastly 0.21.0\n", + b"viceroy \xff\n", + ] { + assert_eq!( + rejection(parse_version_output(output)), + RuntimeRejection::Viceroy, + "should reject version output {:?}", + String::from_utf8_lossy(output) + ); + } +} + +#[test] +fn viceroy_selection_requires_an_absolute_regular_file() { + let directory = tempfile::tempdir().expect("should create a directory"); + for path in [ + std::path::Path::new("viceroy"), + directory.path(), + &directory.path().join("missing"), + ] { + assert_eq!( + rejection(SelectedViceroy::select(path)), + RuntimeRejection::Viceroy, + "should reject Viceroy at {path:?} without running it" + ); + } +} + +#[test] +fn viceroy_selection_rejects_a_symlink() { + let directory = tempfile::tempdir().expect("should create a directory"); + let target = directory.path().join("viceroy-target"); + std::fs::write(&target, b"example executable").expect("should write a stand-in"); + let link = directory.path().join("viceroy"); + std::os::unix::fs::symlink(&target, &link).expect("should create a symlink"); + + assert_eq!( + rejection(SelectedViceroy::select(&link)), + RuntimeRejection::Viceroy, + "should reject a symlinked Viceroy without following or running it" + ); +} + +/// Returns the SHA-256 of a repository file, as the built source tracks it. +fn repository_sha256(path: &str) -> Option { + let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).ok()?; + Some(hex::encode(Sha256::digest(bytes))) +} + +#[test] +fn embedded_fixtures_must_match_the_built_source() { + require_built_source_fixtures(&repository_sha256) + .expect("should accept a built source tracking the embedded fixtures"); + + let changed = |path: &str| { + if path == "edgezero.toml" { + Some("0".repeat(64)) + } else { + repository_sha256(path) + } + }; + let untracked = |path: &str| { + if path.ends_with("viceroy-template.toml") { + None + } else { + repository_sha256(path) + } + }; + for (label, tracked) in [ + ( + "changed manifest", + &changed as &dyn Fn(&str) -> Option, + ), + ("untracked template", &untracked), + ] { + let report = require_built_source_fixtures(tracked).expect_err("should fail closed"); + assert_eq!( + report.downcast_ref::().copied(), + Some(RuntimeRejection::Fixture), + "should reject a {label}" + ); + assert!( + format!("{report:?}").contains("built source"), + "should name the built source difference for a {label}: {report:?}" + ); + } +} + +#[test] +fn clean_viceroy_output_is_summarized() { + let output = scan_viceroy_output(b"Listening on http://127.0.0.1:7676\n", b"") + .expect("should accept clean output"); + assert_eq!( + output.stderr_sha256, + hex::encode(Sha256::digest(b"")), + "should digest the empty stderr" + ); + assert_eq!(output.stderr_tail, ""); +} + +#[test] +fn guest_failures_in_viceroy_output_are_rejected() { + let cases: [(&str, &[u8], &[u8]); 5] = [ + ( + "wasm trap", + b"", + b"2026-10-16T00:00:00Z ERROR request failed: wasm trap: wasm `unreachable` instruction executed\n", + ), + ( + "guest panic", + b"thread '' panicked at src/example.rs:1:1:\nexample\n", + b"", + ), + ( + "error line", + b"", + b"Error: hyper::Error(Connect, \"example\")\n", + ), + ( + "request handling error", + b"", + b"WARN There was an error handling the request: example\n", + ), + ("backtrace", b"wasm backtrace:\n 0: example\n", b""), + ]; + for (label, stdout, stderr) in cases { + assert_eq!( + rejection(scan_viceroy_output(stdout, stderr)), + RuntimeRejection::GuestFailure, + "should reject a {label}" + ); + } +} + +#[test] +fn stderr_tail_is_bounded_and_printable() { + let mut stderr = vec![b'x'; 4096]; + stderr.extend_from_slice(b"\x1b[31mred\x07\nend"); + let tail = sanitized_tail(&stderr); + assert!(tail.len() <= 1024, "should keep a bounded tail"); + assert!( + tail.ends_with("?[31mred?\nend"), + "should replace control characters but keep newlines: {tail:?}" + ); +} + +#[test] +fn config_store_id_matches_the_fastly_local_store() { + let id = config_store_id().expect("should read the default config store"); + let fastly: toml::Table = toml::from_str(FASTLY_TOML).expect("should parse fastly.toml"); + assert!( + fastly["local_server"]["config_stores"].get(&id).is_some(), + "should name a config store the Fastly local server declares: {id}" + ); +} + +#[test] +fn canonical_form_sorts_keys_escapes_and_rejects_floats() { + let value = json!({"b": 1, "a": [true, null, "x\n\"\u{1}"], "B": {"z": -2, "y": {}}}); + assert_eq!( + canonical(&value).expect("should encode integers"), + r#"{"B":{"y":{},"z":-2},"a":[true,null,"x\n\"\u0001"],"b":1}"#, + "should sort keys by bytes and use the fixed escape table" + ); + assert_eq!( + rejection(canonical(&json!({"ratio": 1.5}))), + RuntimeRejection::Fixture, + "should reject floats" + ); +} + +#[test] +fn app_config_keeps_secret_names_and_replaces_only_the_origin() { + let data = app_config_data(ORIGIN).expect("should convert the integration fixture"); + assert_eq!( + data["publisher"]["origin_url"], ORIGIN, + "should override the origin" + ); + assert_eq!( + data["publisher"]["proxy_secret"], "integration_proxy_secret", + "should keep secret store key names, not values" + ); + assert_eq!(data["publisher"]["domain"], "localhost"); +} + +#[test] +fn envelope_binds_the_canonical_data_digest() { + let data = app_config_data(ORIGIN).expect("should convert the fixture"); + let envelope: Value = serde_json::from_str(&envelope_json(&data).expect("should wrap")) + .expect("should parse the envelope"); + let expected = hex::encode(Sha256::digest( + canonical(&data).expect("should encode").as_bytes(), + )); + assert_eq!(envelope["sha256"], Value::String(expected)); + assert_eq!(envelope["version"], 1); + assert_eq!(envelope["generated_at"], "2026-06-23T00:00:00Z"); + assert_eq!(envelope["data"], data, "should carry the data unchanged"); +} + +#[test] +fn viceroy_config_is_exact_and_deterministic() { + let generated = viceroy_config(ORIGIN).expect("should generate the config"); + let again = viceroy_config(ORIGIN).expect("should generate the config again"); + assert_eq!(generated, again, "should be deterministic"); + assert_eq!( + generated.sha256, + hex::encode(Sha256::digest(generated.text.as_bytes())), + "should digest the exact text" + ); + let parsed: toml::Table = toml::from_str(&generated.text).expect("should parse as TOML"); + let id = config_store_id().expect("should read the store ID"); + let store = &parsed["local_server"]["config_stores"][id.as_str()]; + assert_eq!(store["format"].as_str(), Some("inline-toml")); + let envelope: Value = serde_json::from_str( + store["contents"][id.as_str()] + .as_str() + .expect("should store the envelope under the store ID"), + ) + .expect("should parse the stored envelope"); + assert_eq!(envelope["data"]["publisher"]["origin_url"], ORIGIN); + assert!( + parsed["local_server"]["config_stores"] + .get("jwks_store") + .is_some() + && parsed["local_server"]["secret_stores"] + .get("trusted_server_secrets") + .is_some(), + "should keep the rest of the integration template" + ); + assert!( + !generated + .text + .contains("GENERATED_TRUSTED_SERVER_CONFIG_STORES"), + "should replace the marker" + ); + + let other = viceroy_config("http://127.0.0.1:9998").expect("should generate another"); + assert_ne!(other.sha256, generated.sha256, "should bind the origin"); + assert_eq!( + other.fixture_sha256, generated.fixture_sha256, + "should identify the same fixed fixture" + ); +} + +fn html_response(status: u16, body: &str) -> HttpResponse { + HttpResponse { + status, + headers: vec![( + "content-type".to_owned(), + "text/html; charset=utf-8".to_owned(), + )], + body: body.as_bytes().to_vec(), + } +} + +fn injected_page() -> String { + origin_page(MARKER).replacen("", &format!("{SCRIPT}"), 1) +} + +#[test] +fn injected_origin_page_is_accepted() { + let page = injected_page(); + let observed = + check_publisher_response(&html_response(200, &page), MARKER).expect("should accept"); + assert_eq!( + observed.script_src, + "/static/tsjs=tsjs-unified.min.js?v=0123abcd" + ); + assert_eq!(observed.body_size, page.len()); + assert_eq!(observed.status, 200); +} + +#[test] +fn publisher_response_negatives_are_rejected() { + let page = injected_page(); + let health = HttpResponse { + status: 200, + headers: vec![("content-type".to_owned(), "text/plain".to_owned())], + body: b"ok".to_vec(), + }; + let cases: [(&str, HttpResponse, RuntimeRejection); 9] = [ + ( + "bad gateway", + html_response(502, &page), + RuntimeRejection::Status, + ), + ("health-only answer", health, RuntimeRejection::Injection), + ( + "uninjected origin page", + html_response(200, &origin_page(MARKER)), + RuntimeRejection::Injection, + ), + ( + "duplicated script", + html_response(200, &page.replacen("", &format!("{SCRIPT}"), 1)), + RuntimeRejection::Injection, + ), + ( + "wrong script source", + html_response(200, &page.replace("/static/tsjs=", "/static/other=")), + RuntimeRejection::Injection, + ), + ( + "id on a non-script element", + html_response(200, &page.replace("